diff --git a/syncthingmodel/syncthingfilemodel.cpp b/syncthingmodel/syncthingfilemodel.cpp index 96c799c8..a7fa3006 100644 --- a/syncthingmodel/syncthingfilemodel.cpp +++ b/syncthingmodel/syncthingfilemodel.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -84,7 +85,7 @@ SyncthingFileModel::SyncthingFileModel(SyncthingConnection &connection, const Sy connect(this, &SyncthingFileModel::hasStagedChangesChanged, this, &SyncthingFileModel::selectionActionsChanged); if (m_connection.isLocal()) { m_root->existsLocally = true; - m_localPath = dir.pathWithoutTrailingSlash().toString(); + m_localPath = Data::substituteTilde(dir.pathWithoutTrailingSlash().toString(), m_connection.tilde(), m_connection.pathSeparator()); m_columns += 1; connect(&m_localItemLookup, &QFutureWatcherBase::finished, this, &SyncthingFileModel::handleLocalLookupFinished); } @@ -273,6 +274,20 @@ void SyncthingFileModel::editIgnorePatternsManually(const QString &ignorePattern m_manuallyEditedIgnorePatterns = ignorePatterns; } +void SyncthingFileModel::editLocalDeletions(const QSet &localDeletions) +{ + m_manuallyEditedLocalDeletions = localDeletions; +} + +void SyncthingFileModel::editLocalDeletionsFromVariantList(const QVariantList &localDeletions) +{ + m_manuallyEditedLocalDeletions.emplace(); + m_manuallyEditedLocalDeletions->reserve(localDeletions.size()); + for (const auto &path : localDeletions) { + m_manuallyEditedLocalDeletions->insert(path.toString()); + } +} + QModelIndex SyncthingFileModel::parent(const QModelIndex &child) const { if (!child.isValid()) { @@ -679,9 +694,9 @@ template static void forEachItem(SyncthingItem *root, Callba } } -void SyncthingFileModel::ignoreSelectedItems(bool ignore) +void SyncthingFileModel::ignoreSelectedItems(bool ignore, bool deleteLocally) { - forEachItem(m_root.get(), [this, ignore](const SyncthingItem *item) { + forEachItem(m_root.get(), [this, ignore, deleteLocally](const SyncthingItem *item) { if (item->checked != Qt::Checked || !item->isFilesystemItem()) { return true; } @@ -738,6 +753,13 @@ void SyncthingFileModel::ignoreSelectedItems(bool ignore) insertPattern(m_stagedChanges[m_presentIgnorePatterns.size() - 1].append, wantedPattern, path); } + // stage deletion of local file + if (deleteLocally) { + m_stagedLocalFileDeletions.insert(item->path); + } else { + m_stagedLocalFileDeletions.remove(item->path); + } + // prepend the new pattern making sure it is effective and not shadowed by an existing pattern return false; // no need to add ignore patterns for children as they are applied recursively anyway }); @@ -747,7 +769,7 @@ void SyncthingFileModel::ignoreSelectedItems(bool ignore) QList SyncthingFileModel::selectionActions() { auto res = QList(); - res.reserve(8); + res.reserve(9); if (!m_selectionMode) { auto *const startSelectionAction = new QAction(tr("Select items to sync/ignore"), this); startSelectionAction->setIcon(QIcon::fromTheme(QStringLiteral("edit-select"))); @@ -765,7 +787,7 @@ QList SyncthingFileModel::selectionActions() res << discardAction; } } else { - auto *const discardAction = new QAction(tr("Discard selection and staged changes"), this); + auto *const discardAction = new QAction(tr("Uncheck all and discard staged changes"), this); discardAction->setIcon(QIcon::fromTheme(QStringLiteral("edit-undo"))); connect(discardAction, &QAction::triggered, this, [this] { if (const auto rootIndex = index(0, 0); rootIndex.isValid()) { @@ -778,12 +800,19 @@ QList SyncthingFileModel::selectionActions() }); res << discardAction; - auto *const ignoreSelectedAction = new QAction(tr("Ignore selected items (and their children)"), this); + auto *const ignoreSelectedAction = new QAction(tr("Ignore checked items (and their children)"), this); ignoreSelectedAction->setIcon(QIcon::fromTheme(QStringLiteral("list-remove"))); connect(ignoreSelectedAction, &QAction::triggered, this, [this]() { ignoreSelectedItems(); }); res << ignoreSelectedAction; - auto *const includeSelectedAction = new QAction(tr("Include selected items (and their children)"), this); + if (!m_localPath.isEmpty()) { + auto *const ignoreAndDeleteSelectedAction = new QAction(tr("Ignore checked items (and their children) and ensure they are locally deleted"), this); + ignoreAndDeleteSelectedAction->setIcon(QIcon::fromTheme(QStringLiteral("list-remove"))); + connect(ignoreAndDeleteSelectedAction, &QAction::triggered, this, [this]() { ignoreSelectedItems(true, true); }); + res << ignoreAndDeleteSelectedAction; + } + + auto *const includeSelectedAction = new QAction(tr("Include checked items (and their children)"), this); includeSelectedAction->setIcon(QIcon::fromTheme(QStringLiteral("list-add"))); connect(includeSelectedAction, &QAction::triggered, this, [this]() { ignoreSelectedItems(false); }); res << includeSelectedAction; @@ -820,7 +849,7 @@ QList SyncthingFileModel::selectionActions() if (m_selectionMode) { auto *const removeIgnorePatternsAction - = new QAction(tr("Remove ignore patterns matching against selected items (may affect other items as well)"), this); + = new QAction(tr("Remove ignore patterns matching checked items (may affect other items as well)"), this); removeIgnorePatternsAction->setIcon(QIcon::fromTheme(QStringLiteral("edit-delete"))); connect(removeIgnorePatternsAction, &QAction::triggered, this, [this]() { forEachItem(m_root.get(), [this](SyncthingItem *item) { @@ -855,7 +884,8 @@ QList SyncthingFileModel::selectionActions() if (action->needsConfirmation) { action->needsConfirmation = false; m_manuallyEditedIgnorePatterns.clear(); - emit actionNeedsConfirmation(action, tr("Do you want to apply the following changes?"), computeIgnorePatternDiff()); + m_manuallyEditedLocalDeletions.reset(); + emit actionNeedsConfirmation(action, tr("Do you want to apply the following changes?"), computeIgnorePatternDiff(), m_stagedLocalFileDeletions); return; } action->needsConfirmation = true; @@ -877,7 +907,6 @@ QList SyncthingFileModel::selectionActions() // reset state and query ignore patterns again on success so matching ignore patterns are updated m_stagedChanges.clear(); - m_stagedLocalFileDeletions.clear(); m_hasIgnorePatterns = false; forEachItem(m_root.get(), [](SyncthingItem *item) { item->ignorePattern = SyncthingItem::ignorePatternNotInitialized; @@ -885,7 +914,23 @@ QList SyncthingFileModel::selectionActions() }); queryIgnores(); - emit notification(QStringLiteral("info"), tr("Ignore patterns have been changed.")); + // delete local files/directories staged for deletion + const auto &localDeletions = m_manuallyEditedLocalDeletions.value_or(m_stagedLocalFileDeletions); + auto failedDeletions = QStringList(); + for (const auto &path : localDeletions) { + const auto fullPath = QString(m_localPath % m_pathSeparator % path); + if (QFile::moveToTrash(fullPath)) { + m_stagedLocalFileDeletions.remove(path); + } else { + failedDeletions.append(fullPath); + } + } + + if (failedDeletions.isEmpty()) { + emit notification(QStringLiteral("info"), tr("Ignore patterns have been changed.")); + } else { + emit notification(QStringLiteral("info"), tr("Ignore patterns have been changed but the following local files could not be deleted:\n") + failedDeletions.join(QChar('\n'))); + } emit hasStagedChangesChanged(hasStagedChanges()); }); }); diff --git a/syncthingmodel/syncthingfilemodel.h b/syncthingmodel/syncthingfilemodel.h index 7ccff028..7f060930 100644 --- a/syncthingmodel/syncthingfilemodel.h +++ b/syncthingmodel/syncthingfilemodel.h @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -86,14 +87,16 @@ class LIB_SYNCTHING_MODEL_EXPORT SyncthingFileModel : public SyncthingModel { bool hasStagedChanges() const; const std::vector &presentIgnorePatterns() const; SyncthingIgnores computeNewIgnorePatterns() const; - void editIgnorePatternsManually(const QString &ignorePatterns); + Q_INVOKABLE void editIgnorePatternsManually(const QString &ignorePatterns); + Q_INVOKABLE void editLocalDeletions(const QSet &localDeletions); + Q_INVOKABLE void editLocalDeletionsFromVariantList(const QVariantList &localDeletions); bool isRecursiveSelectionEnabled() const; void setRecursiveSelectionEnabled(bool recursiveSelectionEnabled); Q_SIGNALS: void fetchQueueEmpty(); void notification(const QString &type, const QString &message, const QString &details = QString()); - void actionNeedsConfirmation(QAction *action, const QString &message, const QString &diff = QString()); + void actionNeedsConfirmation(QAction *action, const QString &message, const QString &diff = QString(), const QSet &localDeletions = QSet()); void selectionModeEnabledChanged(bool selectionModeEnabled); void selectionActionsChanged(); void hasStagedChangesChanged(bool hasStagedChanged); @@ -111,7 +114,7 @@ private Q_SLOTS: void queryIgnores(); void resetMatchingIgnorePatterns(); void matchItemAgainstIgnorePatterns(SyncthingItem &item) const; - void ignoreSelectedItems(bool ignore = true); + void ignoreSelectedItems(bool ignore = true, bool deleteLocally = false); QString computeIgnorePatternDiff(); QString availabilityNote(const SyncthingItem *item) const; @@ -152,6 +155,7 @@ private Q_SLOTS: QFutureWatcher m_localItemLookup; std::unique_ptr m_root; QString m_manuallyEditedIgnorePatterns; + std::optional> m_manuallyEditedLocalDeletions; QString m_ignoreAllByDefaultPattern; QChar m_pathSeparator; mutable QPixmap m_statusIcons[4]; diff --git a/syncthingmodel/tests/models.cpp b/syncthingmodel/tests/models.cpp index bca7db90..c3e3cc1f 100644 --- a/syncthingmodel/tests/models.cpp +++ b/syncthingmodel/tests/models.cpp @@ -366,9 +366,10 @@ void ModelTests::testFileModel() QCOMPARE(actions.at(5)->text(), QStringLiteral("Review and apply staged changes")); connect( &model, &Data::SyncthingFileModel::actionNeedsConfirmation, this, - [&expectedDiff](QAction *action, const QString &message, const QString &diff = QString()) { + [&expectedDiff](QAction *action, const QString &message, const QString &diff, const QSet &localDeletions) { QCOMPARE(message, QStringLiteral("Do you want to apply the following changes?")); QCOMPARE(diff, expectedDiff); + QCOMPARE(localDeletions, QSet()); action->trigger(); }, Qt::QueuedConnection); diff --git a/syncthingwidgets/misc/otherdialogs.cpp b/syncthingwidgets/misc/otherdialogs.cpp index 117a60c6..7c0e271c 100644 --- a/syncthingwidgets/misc/otherdialogs.cpp +++ b/syncthingwidgets/misc/otherdialogs.cpp @@ -3,6 +3,8 @@ #include "./diffhighlighter.h" #include "./textviewdialog.h" +#include + #include #include @@ -21,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -150,13 +153,26 @@ QDialog *browseRemoteFilesDialog(Data::SyncthingConnection &connection, const Da messageBox.exec(); }); QObject::connect( - model, &Data::SyncthingFileModel::actionNeedsConfirmation, dlg, [model](QAction *action, const QString &message, const QString &details) { + model, &Data::SyncthingFileModel::actionNeedsConfirmation, dlg, [model](QAction *action, const QString &message, const QString &details, const QSet &localDeletions) { auto messageBox = TextViewDialog(QStringLiteral("Confirm action - " APP_NAME)); + auto deletionList = QString(); + if (!localDeletions.isEmpty()) { + deletionList = QCoreApplication::translate("QtGui::OtherDialogs", "Deletion of the following local files:"); + auto requiredSize = deletionList.size() + localDeletions.size(); + for (const auto &path : localDeletions) { + requiredSize += path.size(); + } + deletionList.reserve(requiredSize); + for (const auto &path : localDeletions) { + deletionList += QChar('\n'); + deletionList += path; + } + } auto *const browser = messageBox.browser(); auto *const highlighter = new DiffHighlighter(browser->document()); auto *const buttonLayout = new QHBoxLayout(&messageBox); auto *const editBtn = new QPushButton( - QIcon::fromTheme(QStringLiteral("document-edit")), QCoreApplication::translate("QtGui::OtherDialogs", "Edit manually"), &messageBox); + QIcon::fromTheme(QStringLiteral("document-edit")), QCoreApplication::translate("QtGui::OtherDialogs", "Edit patterns manually"), &messageBox); auto *const yesBtn = new QPushButton( QIcon::fromTheme(QStringLiteral("dialog-ok")), QCoreApplication::translate("QtGui::OtherDialogs", "Apply"), &messageBox); auto *const noBtn = new QPushButton( @@ -178,6 +194,20 @@ QDialog *browseRemoteFilesDialog(Data::SyncthingConnection &connection, const Da buttonLayout->addWidget(noBtn); browser->setText(details); messageBox.layout()->insertWidget(0, new QLabel(message, &messageBox)); + auto *deletionModel = localDeletions.isEmpty() ? nullptr : new QtUtilities::ChecklistModel(&messageBox); + if (deletionModel) { + auto *deletionView = new QListView(&messageBox); + auto deletionItems = QList(); + deletionItems.reserve(localDeletions.size()); + for (const auto &path : localDeletions) { + deletionItems.emplace_back(path, path, Qt::Checked); + } + deletionModel->setItems(deletionItems); + deletionView->setModel(deletionModel); + messageBox.layout()->insertWidget(1, new QLabel(QCoreApplication::translate("QtGui::OtherDialogs", "Deletion of the following local files:"), &messageBox)); + messageBox.layout()->insertWidget(2, deletionView); + messageBox.layout()->insertWidget(3, new QLabel(QCoreApplication::translate("QtGui::OtherDialogs", "Changes to ignore patterns:"), &messageBox)); + } messageBox.layout()->addLayout(buttonLayout); messageBox.setAttribute(Qt::WA_DeleteOnClose, false); action->setParent(&messageBox); @@ -187,6 +217,16 @@ QDialog *browseRemoteFilesDialog(Data::SyncthingConnection &connection, const Da if (!browser->isReadOnly()) { model->editIgnorePatternsManually(browser->toPlainText()); } + if (deletionModel) { + auto editedLocalDeletions = QSet(); + editedLocalDeletions.reserve(deletionModel->items().size()); + for (const auto &item : deletionModel->items()) { + if (item.isChecked()) { + editedLocalDeletions.insert(item.id().toString()); + } + } + model->editLocalDeletions(editedLocalDeletions); + } action->trigger(); }); diff --git a/tray/gui/qml/FilesPage.qml b/tray/gui/qml/FilesPage.qml index 734af135..7e6bd22c 100644 --- a/tray/gui/qml/FilesPage.qml +++ b/tray/gui/qml/FilesPage.qml @@ -112,20 +112,68 @@ Page { CustomDialog { id: confirmActionDialog contentItem: ColumnLayout { + id: confirmActionLayout Label { id: messageLabel Layout.fillWidth: true + font.weight: Font.Medium + wrapMode: Text.WordWrap } ScrollView { Layout.fillWidth: true Layout.fillHeight: true - TextArea { - id: diffTextArea - readOnly: true + ColumnLayout { + width: confirmActionLayout.width + CustomListView { + id: deletionsView + Layout.fillWidth: true + Layout.preferredHeight: deletionsView.contentHeight + model: ListModel { + id: deletionsModel + } + header: Label { + width: deletionsView.width + visible: deletionsView.model.count > 0 + text: qsTr("Deletion of the following local files:") + font.weight: Font.Light + wrapMode: Text.WordWrap + } + delegate: CheckDelegate { + id: deletionDelegate + width: deletionsView.width + text: modelData.path + checkState: modelData.checked ? Qt.Checked : Qt.Unchecked + onCheckedChanged: deletionsModel.setProperty(modelData.index, "checked", deletionDelegate.checked) + required property var modelData + } + } + Label { + Layout.fillWidth: true + visible: deletionsView.model.count > 0 + text: qsTr("Changes to ignore patterns:") + font.weight: Font.Light + wrapMode: Text.WordWrap + } + TextArea { + id: diffTextArea + Layout.fillWidth: true + readOnly: true + } + } + } + } + onAccepted: { + const localDeletions = []; + const localDeletionCount = deletionsModel.count; + for (let i = 0; i !== localDeletionCount; ++i) { + const localDeletion = deletionsModel.get(i); + if (localDeletion.checked) { + localDeletions.push(localDeletion.path); } } + page.model.editLocalDeletionsFromVariantList(localDeletions); + action?.trigger() } - onAccepted: action?.trigger() onRejected: action?.dismiss() property var action property var diffHighlighter: App.createDiffHighlighter(diffTextArea.textDocument.textDocument) @@ -140,11 +188,16 @@ Page { modelActionsInstantiator.model = page.model.selectionActions; page.extraActions = page.modelActions; } - function onActionNeedsConfirmation(action, message, diff) { + function onActionNeedsConfirmation(action, message, diff, localDeletions) { confirmActionDialog.title = action.text; confirmActionDialog.action = action; confirmActionDialog.message = message; confirmActionDialog.diff = diff; + deletionsModel.clear(); + let index = 0; + for (const path of localDeletions) { + deletionsModel.append({path: path, checked: true, index: index++}); + } confirmActionDialog.open(); } }