Переглянути джерело

Merge pull request #5635 from nextcloud/feature/resolveAllConflicts

Feature/resolve all conflicts
Matthieu Gallien 2 роки тому
батько
коміт
d7d52a11c1

+ 3 - 0
resources.qrc

@@ -56,5 +56,8 @@
         <file>src/gui/tray/ListItemLineAndSubline.qml</file>
         <file>src/gui/tray/TrayFoldersMenuButton.qml</file>
         <file>src/gui/tray/TrayFolderListItem.qml</file>
+        <file>src/gui/ResolveConflictsDialog.qml</file>
+        <file>src/gui/ConflictDelegate.qml</file>
+        <file>src/gui/ConflictItemFileInfo.qml</file>
     </qresource>
 </RCC>

+ 2 - 0
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

+ 86 - 0
src/gui/ConflictDelegate.qml

@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2023 by Matthieu Gallien <matthieu.gallien@nextcloud.com>
+ *
+ * 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
+            }
+        }
+    }
+}

+ 98 - 0
src/gui/ConflictItemFileInfo.qml

@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2023 by Matthieu Gallien <matthieu.gallien@nextcloud.com>
+ *
+ * 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
+        }
+    }
+}

+ 174 - 0
src/gui/ResolveConflictsDialog.qml

@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2023 by Matthieu Gallien <matthieu.gallien@nextcloud.com>
+ *
+ * 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
+    }
+}

+ 1 - 0
src/gui/conflictsolver.h

@@ -32,6 +32,7 @@ public:
         KeepRemoteVersion,
         KeepBothVersions
     };
+    Q_ENUM(Solution);
 
     explicit ConflictSolver(QWidget *parent = nullptr);
 

+ 2 - 0
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:
     explicit FolderMan(QObject *parent = nullptr);
     friend class OCC::Application;
     friend class ::TestFolderMan;
+    friend class ::TestSyncConflictsModel;
     friend class ::TestCfApiShellExtensionsIPC;
     friend class ::ShareTestHelper;
     friend class ::EndToEndTestHelper;

+ 1 - 1
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.
 

+ 3 - 0
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<ShareModel>("com.nextcloud.desktopclient", 1, 0, "ShareModel");
     qmlRegisterType<ShareeModel>("com.nextcloud.desktopclient", 1, 0, "ShareeModel");
     qmlRegisterType<SortedShareModel>("com.nextcloud.desktopclient", 1, 0, "SortedShareModel");
+    qmlRegisterType<SyncConflictsModel>("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel");
 
     qmlRegisterUncreatableType<UnifiedSearchResultsListModel>("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel");
     qmlRegisterUncreatableType<UserStatus>("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum");
@@ -138,6 +140,7 @@ ownCloudGui::ownCloudGui(Application *parent)
     qRegisterMetaType<SharePtr>("SharePtr");
     qRegisterMetaType<ShareePtr>("ShareePtr");
     qRegisterMetaType<Sharee>("Sharee");
+    qRegisterMetaType<OCC::ActivityList>("ActivityList");
 
     qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserModel", UserModel::instance());
     qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserAppsModel", UserAppsModel::instance());

+ 343 - 0
src/gui/syncconflictsmodel.cpp

@@ -0,0 +1,343 @@
+/*
+ * Copyright (C) 2023 by Matthieu Gallien <matthieu.gallien@nextcloud.com>
+ *
+ * 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 <QLoggingCategory>
+
+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<int>(SyncConflictRoles::ExistingFileName) && role <= static_cast<int>(SyncConflictRoles::ConflictPreviewUrl)) {
+        const auto convertedRole = static_cast<SyncConflictRoles>(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<int>(SyncConflictRoles::ExistingFileName) && role <= static_cast<int>(SyncConflictRoles::ConflictPreviewUrl)) {
+        const auto convertedRole = static_cast<SyncConflictRoles>(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<int, QByteArray> SyncConflictsModel::roleNames() const
+{
+    auto result = QAbstractListModel::roleNames();
+
+    result[static_cast<int>(SyncConflictRoles::ExistingFileName)] = "existingFileName";
+    result[static_cast<int>(SyncConflictRoles::ExistingSize)] = "existingSize";
+    result[static_cast<int>(SyncConflictRoles::ConflictSize)] = "conflictSize";
+    result[static_cast<int>(SyncConflictRoles::ExistingDate)] = "existingDate";
+    result[static_cast<int>(SyncConflictRoles::ConflictDate)] = "conflictDate";
+    result[static_cast<int>(SyncConflictRoles::ExistingSelected)] = "existingSelected";
+    result[static_cast<int>(SyncConflictRoles::ConflictSelected)] = "conflictSelected";
+    result[static_cast<int>(SyncConflictRoles::ExistingPreviewUrl)] = "existingPreviewUrl";
+    result[static_cast<int>(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<int>(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<int>(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;
+}
+
+}

+ 131 - 0
src/gui/syncconflictsmodel.h

@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2023 by Matthieu Gallien <matthieu.gallien@nextcloud.com>
+ *
+ * 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 <QAbstractListModel>
+#include <QMimeDatabase>
+#include <QLocale>
+
+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<int,QByteArray> 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<ConflictInfo> _conflictData;
+
+    QLocale _locale;
+
+    bool _allExistingsSelected = false;
+
+    bool _allConflictingsSelected = false;
+};
+
+}

+ 30 - 0
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<QQmlComponent>(_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<QQuickWindow*>(dialog.data());
+    if (!dialogWindow) {
+        return;
+    }
+    dialogWindow->show();
+    dialogWindow->raise();
+    dialogWindow->requestActivate();
+    dialog.take();
+}
+
 bool Systray::raiseDialogs()
 {
     return raiseFileDetailDialogs();

+ 1 - 0
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();
 

+ 23 - 1
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);
+    }
 }

+ 1 - 0
src/gui/tray/activitydata.h

@@ -178,6 +178,7 @@ using ActivityList = QList<Activity>;
 }
 
 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)

+ 70 - 28
src/gui/tray/activitylistmodel.cpp

@@ -32,7 +32,7 @@
 #include <QWidget>
 #include <QJsonObject>
 #include <QJsonDocument>
-#include <qloggingcategory.h>
+#include <QLoggingCategory>
 
 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;
+}
+
 }

+ 13 - 0
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 @@ public:
 
     [[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:
     void insertOrRemoveDummyFetchingActivity();
     void triggerCaseClashAction(Activity activity);
 
+    void displaySingleConflictDialog(const Activity &activity);
+    void setHasSyncConflicts(bool conflictsFound);
+
     Activity _notificationIgnoredFiles;
     Activity _dummyFetchingActivities;
 
@@ -190,6 +201,8 @@ private:
     bool _doneFetching = false;
     bool _hideOldActivities = true;
 
+    bool _hasSyncConflicts = false;
+
     static constexpr quint32 MaxActionButtons = 3;
 };
 }

+ 1 - 0
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)

+ 115 - 0
test/testsyncconflictsmodel.cpp

@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) by Claudio Cambra <claudio.cambra@nextcloud.com>
+ *
+ * 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 <QTest>
+#include <QAbstractItemModelTester>
+#include <QSignalSpy>
+
+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<int>(SyncConflictsModel::SyncConflictRoles::ExistingFileName)), QString{"a2"});
+        QCOMPARE(model.data(model.index(0), static_cast<int>(SyncConflictsModel::SyncConflictRoles::ExistingSize)), QString{"6 bytes"});
+        QCOMPARE(model.data(model.index(0), static_cast<int>(SyncConflictsModel::SyncConflictRoles::ConflictSize)), QString{"5 bytes"});
+        QVERIFY(!model.data(model.index(0), static_cast<int>(SyncConflictsModel::SyncConflictRoles::ExistingDate)).toString().isEmpty());
+        QVERIFY(!model.data(model.index(0), static_cast<int>(SyncConflictsModel::SyncConflictRoles::ConflictDate)).toString().isEmpty());
+        QCOMPARE(model.data(model.index(0), static_cast<int>(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<int>(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<int>(SyncConflictsModel::SyncConflictRoles::ExistingSelected)), false);
+        QCOMPARE(model.data(model.index(0), static_cast<int>(SyncConflictsModel::SyncConflictRoles::ConflictSelected)), false);
+    }
+};
+
+QTEST_GUILESS_MAIN(TestSyncConflictsModel)
+#include "testsyncconflictsmodel.moc"

+ 5 - 0
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