diff --git a/io/task.cpp b/io/task.cpp index b6ab8b6..a02d94c 100644 --- a/io/task.cpp +++ b/io/task.cpp @@ -53,6 +53,15 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx, const auto legacyProgress = ctx.progress; const auto conflictResolver = ctx.conflict; + // The folder a source goes into; Rename keeps its full destination path here. + const auto destinationOf = [&](qsizetype index) { + if (request.targets.isEmpty()) + return request.destination; + + return request.targets.value(index).adjusted(QUrl::RemoveFilename + | QUrl::StripTrailingSlash); + }; + auto update = [&](bool force = false) { info.elapsedMs = elapsed.isValid() ? elapsed.elapsed() - pausedMs : 0; report(info, force); @@ -87,9 +96,10 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx, if (request.operation == Operation::Move || request.operation == Operation::Rename || request.operation == Operation::Trash || request.operation == Operation::Delete) { - for (const auto &url : request.sources) { + for (qsizetype i = 0; i < request.sources.size(); ++i) { + const auto &url = request.sources.at(i); const bool allowedHomeMove = request.operation == Operation::Move - && canMoveStandardFolderTo(url, request.destination); + && canMoveStandardFolderTo(url, destinationOf(i)); if (isStandardFolder(url) && !allowedHomeMove) throw Failure(EPERM, url.toLocalFile()); } @@ -116,8 +126,10 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx, const auto source = FileSystemUrl(url).localPath(); LocalFileSystem::directorySize(source, scan); + // Items going to their own folders are not checked up front. if ((request.operation == Operation::Copy || request.operation == Operation::Move) + && request.targets.isEmpty() && takesSpace(source, FileSystemUrl(request.destination).localPath(), request.operation == Operation::Move)) required += scan.allocated; @@ -172,10 +184,16 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx, case Operation::Move: case Operation::Link: case Operation::Rename: { - const auto destination = FileSystemUrl(request.destination).localPath(); - const auto target = request.operation == Operation::Rename - ? destination - : QDir(destination).filePath(url.fileName()); + QString target; + if (!request.targets.isEmpty()) { + target = FileSystemUrl(request.targets.value(sourceIndex)).localPath(); + } else { + const auto destination = FileSystemUrl(request.destination).localPath(); + target = request.operation == Operation::Rename + ? destination + : QDir(destination).filePath(url.fileName()); + } + const auto path = request.operation == Operation::Link ? LocalFileSystem::link(source, target, request.conflict, ctx) : request.operation == Operation::Copy @@ -202,8 +220,10 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx, } result.completedSources.append(url); - if (!output.isEmpty()) + if (!output.isEmpty()) { result.outputs.append(output); + result.outputSources.append(url); + } if (info.totalsKnown && sourceIndex < weights.size()) { creditedBytes += weights[sourceIndex].first; @@ -406,30 +426,56 @@ TaskController::~TaskController() Task *TaskController::submit(const TaskRequest &request) { - return submit(request, false); + return submit(request, History::Fresh); +} + +// What puts back the part of an operation that was done, if anything can. +static std::optional inverse(const TaskRequest &request, const TaskResult &result) +{ + if (result.outputs.isEmpty()) + return {}; + + switch (request.operation) { + case Operation::Copy: + case Operation::Link: + case Operation::Mkdir: + case Operation::Restore: + return TaskRequest{Operation::Trash, result.outputs}; + case Operation::Trash: + return TaskRequest{Operation::Restore, result.outputs, {}, ConflictPolicy::Ask}; + case Operation::Move: + case Operation::Rename: { + // Out of a trash, the item would go back without its info file. + for (const auto &source : result.outputSources) { + if (!source.isLocalFile()) + return {}; + } + + if (result.outputSources.size() != result.outputs.size()) + return {}; + + TaskRequest back{Operation::Move, result.outputs, {}, ConflictPolicy::Ask}; + back.targets = result.outputSources; + return back; + } + default: + return {}; + } } // A fresh operation forks the history: what was undone before it can no // longer be redone. Undo and redo themselves keep the other stack. -Task *TaskController::submit(const TaskRequest &request, bool keepRedo) +Task *TaskController::submit(const TaskRequest &request, History history) { - if (!keepRedo && !m_redo.isEmpty()) { + if (history == History::Fresh && !m_redo.isEmpty()) { m_redo.clear(); emit historyChanged(); } auto *task = new Task(request, this); - connect(task, &Task::result, this, [this, request](Task *task) { - const auto &result = task->outcome(); - - if (request.operation == Operation::Trash && !result.outputs.isEmpty()) { - m_undo.append({Operation::Restore, result.outputs}); - while (m_undo.size() > kMaxHistory) - m_undo.removeFirst(); - emit historyChanged(); - } - + connect(task, &Task::result, this, [this, request, history](Task *task) { + record(request, task->outcome(), history); emit taskFinished(task); task->deleteLater(); }); @@ -439,6 +485,52 @@ Task *TaskController::submit(const TaskRequest &request, bool keepRedo) return task; } +void TaskController::record(const TaskRequest &request, const TaskResult &result, + History history) +{ + bool changed = false; + + if (const auto back = inverse(request, result)) { + auto &stack = history == History::Undo ? m_redo : m_undo; + stack.append(*back); + while (stack.size() > kMaxHistory) + stack.removeFirst(); + changed = true; + } + + // A cancelled undo or redo keeps what it did not reach; a failed one is dropped, as + // retrying would fail the same way. + if (history != History::Fresh && result.error.code == ECANCELED) { + auto remaining = request; + for (const auto &source : result.completedSources) { + const auto index = remaining.sources.indexOf(source); + if (index < 0) + continue; + + remaining.sources.removeAt(index); + if (index < remaining.targets.size()) + remaining.targets.removeAt(index); + } + + if (!remaining.sources.isEmpty()) { + (history == History::Undo ? m_undo : m_redo).append(remaining); + changed = true; + } + } + + // Emptied trash items cannot come back. + if (request.operation == Operation::EmptyTrash && !result.error) { + const auto restores = [](const TaskRequest &entry) { + return entry.operation == Operation::Restore; + }; + changed |= m_undo.removeIf(restores) > 0; + changed |= m_redo.removeIf(restores) > 0; + } + + if (changed) + emit historyChanged(); +} + Task *TaskController::undo() { if (m_undo.isEmpty()) @@ -446,28 +538,9 @@ Task *TaskController::undo() const auto request = m_undo.takeLast(); emit historyChanged(); - auto *task = submit(request, true); - - connect(task, &Task::result, this, [this, request](Task *done) { - const auto &result = done->outcome(); - if (!result.outputs.isEmpty()) - m_redo.append({Operation::Trash, result.outputs}); - - if (done->error()) { - auto remaining = request; - for (const auto &source : result.completedSources) - remaining.sources.removeAll(source); - if (!remaining.sources.isEmpty()) - m_undo.append(remaining); - } - - emit historyChanged(); - }); - - return task; + return submit(request, History::Undo); } -// Redoing a trash goes through the normal path, which records its undo again. Task *TaskController::redo() { if (m_redo.isEmpty()) @@ -475,22 +548,7 @@ Task *TaskController::redo() const auto request = m_redo.takeLast(); emit historyChanged(); - auto *task = submit(request, true); - - connect(task, &Task::result, this, [this, request](Task *done) { - if (done->error()) { - auto remaining = request; - for (const auto &source : done->outcome().completedSources) - remaining.sources.removeAll(source); - - if (!remaining.sources.isEmpty()) { - m_redo.append(remaining); - emit historyChanged(); - } - } - }); - - return task; + return submit(request, History::Redo); } Task *copy(const QList &sources, const QUrl &destination) diff --git a/io/task.h b/io/task.h index 66a031f..f530fce 100644 --- a/io/task.h +++ b/io/task.h @@ -32,12 +32,16 @@ struct TaskRequest QList sources; QUrl destination; ConflictPolicy conflict = ConflictPolicy::Fail; + // When set, Copy, Move and Link put each source at its own path instead of into destination. + QList targets; }; struct TaskResult { Error error; QList outputs; + // The source each output came from; empty for Mkdir. + QList outputSources; QList completedSources; quint64 bytes = 0; quint64 files = 0; @@ -177,9 +181,16 @@ signals: void historyChanged(); private: - Task *submit(const TaskRequest &request, bool keepRedo); + enum class History { + Fresh, + Undo, + Redo + }; + + Task *submit(const TaskRequest &request, History history); + void record(const TaskRequest &request, const TaskResult &result, History history); - // Trash operations each add an entry; a long session must not keep them all. + // Each undoable operation adds an entry; a long session must not keep them all. static constexpr int kMaxHistory = 50; QList m_undo; diff --git a/io/tests/tst_io.cpp b/io/tests/tst_io.cpp index 469234e..5fd4889 100644 --- a/io/tests/tst_io.cpp +++ b/io/tests/tst_io.cpp @@ -668,7 +668,7 @@ private slots: QCOMPARE(errorCode([&] { LocalFileSystem::restore(orphan.url(), ConflictPolicy::Fail, ctx); }), - EACCES); + ENOENT); QVERIFY(QDir().mkpath(volume + "/docs")); QCOMPARE(LocalFileSystem::restore(old.url(), ConflictPolicy::Fail, ctx), @@ -1495,6 +1495,141 @@ private slots: QVERIFY(!controller.isRedoAvailable()); } + void controllerCopyUndo() + { + QTemporaryDir dir; + QVERIFY(QDir().mkpath(dir.filePath("target"))); + writeFile(dir.filePath("file"), "data"); + TaskController controller; + QSignalSpy finished(&controller, &TaskController::taskFinished); + + controller.submit({Operation::Copy, + {local(dir.filePath("file"))}, + local(dir.filePath("target"))}); + QTRY_COMPARE(finished.size(), 1); + QVERIFY(controller.undo()); + QTRY_COMPARE(finished.size(), 2); + QVERIFY(!QFileInfo::exists(dir.filePath("target/file"))); + QVERIFY(QFileInfo::exists(dir.filePath("file"))); + QCOMPARE(LocalFileSystem::enumerate(QUrl("trash:/")).size(), 1); + + // Redo brings the undone copy back from the trash. + QVERIFY(controller.redo()); + QTRY_COMPARE(finished.size(), 3); + QCOMPARE(readFile(dir.filePath("target/file")), QByteArray("data")); + QVERIFY(controller.isUndoAvailable()); + QVERIFY(!controller.isRedoAvailable()); + } + + void controllerMoveUndo() + { + QTemporaryDir dir; + QVERIFY(QDir().mkpath(dir.filePath("a"))); + QVERIFY(QDir().mkpath(dir.filePath("b"))); + QVERIFY(QDir().mkpath(dir.filePath("target"))); + writeFile(dir.filePath("a/one"), "one"); + writeFile(dir.filePath("b/two"), "two"); + writeFile(dir.filePath("target/two"), "old"); + TaskController controller; + QSignalSpy finished(&controller, &TaskController::taskFinished); + + controller.submit({Operation::Move, + {local(dir.filePath("a/one")), local(dir.filePath("b/two"))}, + local(dir.filePath("target")), + ConflictPolicy::KeepBoth}); + QTRY_COMPARE(finished.size(), 1); + QCOMPARE(finished.first().first().value()->errorString(), QString()); + QCOMPARE(readFile(dir.filePath("target/two (1)")), QByteArray("two")); + + // Each item goes back to its own folder under its old name. + QVERIFY(controller.undo()); + QTRY_COMPARE(finished.size(), 2); + QCOMPARE(readFile(dir.filePath("a/one")), QByteArray("one")); + QCOMPARE(readFile(dir.filePath("b/two")), QByteArray("two")); + QCOMPARE(readFile(dir.filePath("target/two")), QByteArray("old")); + QVERIFY(!QFileInfo::exists(dir.filePath("target/two (1)"))); + + QVERIFY(controller.redo()); + QTRY_COMPARE(finished.size(), 3); + QCOMPARE(readFile(dir.filePath("target/one")), QByteArray("one")); + QCOMPARE(readFile(dir.filePath("target/two (1)")), QByteArray("two")); + } + + void controllerRenameAndMkdirUndo() + { + QTemporaryDir dir; + TaskController controller; + QSignalSpy finished(&controller, &TaskController::taskFinished); + + controller.submit({Operation::Mkdir, {}, local(dir.filePath("New Folder"))}); + QTRY_COMPARE(finished.size(), 1); + controller.submit({Operation::Rename, + {local(dir.filePath("New Folder"))}, + local(dir.filePath("Photos"))}); + QTRY_COMPARE(finished.size(), 2); + + QVERIFY(controller.undo()); + QTRY_COMPARE(finished.size(), 3); + QVERIFY(QFileInfo(dir.filePath("New Folder")).isDir()); + QVERIFY(!QFileInfo::exists(dir.filePath("Photos"))); + + QVERIFY(controller.undo()); + QTRY_COMPARE(finished.size(), 4); + QVERIFY(!QFileInfo::exists(dir.filePath("New Folder"))); + QVERIFY(!controller.isUndoAvailable()); + + QVERIFY(controller.redo()); + QTRY_COMPARE(finished.size(), 5); + QVERIFY(controller.redo()); + QTRY_COMPARE(finished.size(), 6); + QVERIFY(QFileInfo(dir.filePath("Photos")).isDir()); + } + + void controllerFailedUndoIsDropped() + { + QTemporaryDir dir; + writeFile(dir.filePath("file")); + TaskController controller; + QSignalSpy finished(&controller, &TaskController::taskFinished); + + controller.submit({Operation::Rename, + {local(dir.filePath("file"))}, + local(dir.filePath("renamed"))}); + QTRY_COMPARE(finished.size(), 1); + QVERIFY(QFile::remove(dir.filePath("renamed"))); + + auto *task = controller.undo(); + QVERIFY(task); + QSignalSpy result(task, &Task::result); + QTRY_COMPARE(result.size(), 1); + QCOMPARE(task->error(), ENOENT); + QVERIFY(!controller.isUndoAvailable()); + QVERIFY(!controller.isRedoAvailable()); + } + + void controllerEmptyTrashForgetsRestores() + { + QTemporaryDir dir; + writeFile(dir.filePath("file")); + writeFile(dir.filePath("other")); + TaskController controller; + QSignalSpy finished(&controller, &TaskController::taskFinished); + + controller.submit({Operation::Trash, {local(dir.filePath("file"))}}); + QTRY_COMPARE(finished.size(), 1); + controller.submit({Operation::Rename, + {local(dir.filePath("other"))}, + local(dir.filePath("renamed"))}); + QTRY_COMPARE(finished.size(), 2); + controller.submit({Operation::EmptyTrash}); + QTRY_COMPARE(finished.size(), 3); + + QVERIFY(controller.undo()); + QTRY_COMPARE(finished.size(), 4); + QVERIFY(QFileInfo::exists(dir.filePath("other"))); + QVERIFY(!controller.isUndoAvailable()); + } + void crossDeviceMove() { const auto root = qEnvironmentVariable("CUTEFISH_IO_CROSS_DEVICE_ROOT"); diff --git a/io/trash.cpp b/io/trash.cpp index f35d415..b038394 100644 --- a/io/trash.cpp +++ b/io/trash.cpp @@ -260,7 +260,7 @@ QUrl LocalFileSystem::restore(const QUrl &url, ConflictPolicy policy, OperationC const auto info = infoPath(trash, name); TrashInfo details; if (!readTrashInfo(trash, name, &details)) - throw Failure(EACCES, info); + throw Failure(QFileInfo::exists(info) ? EACCES : ENOENT, info); if (details.path.isEmpty()) throw Failure(EINVAL, info);