Skip to content

Commit

Permalink
Allow deleting files locally after ignoring them
Browse files Browse the repository at this point in the history
  • Loading branch information
Martchus committed Jan 29, 2025
1 parent b8d5ec5 commit 9bc6fd6
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 22 deletions.
67 changes: 56 additions & 11 deletions syncthingmodel/syncthingfilemodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <c++utilities/conversion/stringconversion.h>

#include <QClipboard>
#include <QFile>
#include <QGuiApplication>
#include <QNetworkReply>
#include <QPainter>
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -273,6 +274,20 @@ void SyncthingFileModel::editIgnorePatternsManually(const QString &ignorePattern
m_manuallyEditedIgnorePatterns = ignorePatterns;
}

void SyncthingFileModel::editLocalDeletions(const QSet<QString> &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()) {
Expand Down Expand Up @@ -679,9 +694,9 @@ template <typename Callback> 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;
}
Expand Down Expand Up @@ -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
});
Expand All @@ -747,7 +769,7 @@ void SyncthingFileModel::ignoreSelectedItems(bool ignore)
QList<QAction *> SyncthingFileModel::selectionActions()
{
auto res = QList<QAction *>();
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")));
Expand All @@ -765,7 +787,7 @@ QList<QAction *> 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()) {
Expand All @@ -778,12 +800,19 @@ QList<QAction *> 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;
Expand Down Expand Up @@ -820,7 +849,7 @@ QList<QAction *> 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) {
Expand Down Expand Up @@ -855,7 +884,8 @@ QList<QAction *> 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;
Expand All @@ -877,15 +907,30 @@ QList<QAction *> 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;
return true;
});
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());
});
});
Expand Down
10 changes: 7 additions & 3 deletions syncthingmodel/syncthingfilemodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <QPixmap>
#include <QSet>

#include <optional>
#include <map>
#include <memory>
#include <vector>
Expand Down Expand Up @@ -86,14 +87,16 @@ class LIB_SYNCTHING_MODEL_EXPORT SyncthingFileModel : public SyncthingModel {
bool hasStagedChanges() const;
const std::vector<SyncthingIgnorePattern> &presentIgnorePatterns() const;
SyncthingIgnores computeNewIgnorePatterns() const;
void editIgnorePatternsManually(const QString &ignorePatterns);
Q_INVOKABLE void editIgnorePatternsManually(const QString &ignorePatterns);
Q_INVOKABLE void editLocalDeletions(const QSet<QString> &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<QString> &localDeletions = QSet<QString>());
void selectionModeEnabledChanged(bool selectionModeEnabled);
void selectionActionsChanged();
void hasStagedChangesChanged(bool hasStagedChanged);
Expand All @@ -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;

Expand Down Expand Up @@ -152,6 +155,7 @@ private Q_SLOTS:
QFutureWatcher<LocalLookupRes> m_localItemLookup;
std::unique_ptr<SyncthingItem> m_root;
QString m_manuallyEditedIgnorePatterns;
std::optional<QSet<QString>> m_manuallyEditedLocalDeletions;
QString m_ignoreAllByDefaultPattern;
QChar m_pathSeparator;
mutable QPixmap m_statusIcons[4];
Expand Down
3 changes: 2 additions & 1 deletion syncthingmodel/tests/models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<QString> &localDeletions) {
QCOMPARE(message, QStringLiteral("Do you want to apply the following changes?"));
QCOMPARE(diff, expectedDiff);
QCOMPARE(localDeletions, QSet<QString>());
action->trigger();
},
Qt::QueuedConnection);
Expand Down
44 changes: 42 additions & 2 deletions syncthingwidgets/misc/otherdialogs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include "./diffhighlighter.h"
#include "./textviewdialog.h"

#include <qtutilities/models/checklistmodel.h>

#include <syncthingconnector/syncthingconnection.h>
#include <syncthingconnector/syncthingdir.h>

Expand All @@ -21,6 +23,7 @@
#include <QIcon>
#include <QItemSelectionModel>
#include <QLabel>
#include <QListView>
#include <QMenu>
#include <QMessageBox>
#include <QNetworkReply>
Expand Down Expand Up @@ -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<QString> &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(
Expand All @@ -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<QtUtilities::ChecklistItem>();
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);
Expand All @@ -187,6 +217,16 @@ QDialog *browseRemoteFilesDialog(Data::SyncthingConnection &connection, const Da
if (!browser->isReadOnly()) {
model->editIgnorePatternsManually(browser->toPlainText());
}
if (deletionModel) {
auto editedLocalDeletions = QSet<QString>();
editedLocalDeletions.reserve(deletionModel->items().size());
for (const auto &item : deletionModel->items()) {
if (item.isChecked()) {
editedLocalDeletions.insert(item.id().toString());
}
}
model->editLocalDeletions(editedLocalDeletions);
}
action->trigger();
});

Expand Down
63 changes: 58 additions & 5 deletions tray/gui/qml/FilesPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
}
}
Expand Down

0 comments on commit 9bc6fd6

Please sign in to comment.