feat(io): undo and redo copies, moves, renames and new folders

pull/18/head
reionwong 2 weeks ago
parent 81e9fe3ea6
commit 6a74a6fb46

@ -53,6 +53,15 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx,
const auto legacyProgress = ctx.progress; const auto legacyProgress = ctx.progress;
const auto conflictResolver = ctx.conflict; 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) { auto update = [&](bool force = false) {
info.elapsedMs = elapsed.isValid() ? elapsed.elapsed() - pausedMs : 0; info.elapsedMs = elapsed.isValid() ? elapsed.elapsed() - pausedMs : 0;
report(info, force); report(info, force);
@ -87,9 +96,10 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx,
if (request.operation == Operation::Move || request.operation == Operation::Rename if (request.operation == Operation::Move || request.operation == Operation::Rename
|| request.operation == Operation::Trash || request.operation == Operation::Delete) { || 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 const bool allowedHomeMove = request.operation == Operation::Move
&& canMoveStandardFolderTo(url, request.destination); && canMoveStandardFolderTo(url, destinationOf(i));
if (isStandardFolder(url) && !allowedHomeMove) if (isStandardFolder(url) && !allowedHomeMove)
throw Failure(EPERM, url.toLocalFile()); throw Failure(EPERM, url.toLocalFile());
} }
@ -116,8 +126,10 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx,
const auto source = FileSystemUrl(url).localPath(); const auto source = FileSystemUrl(url).localPath();
LocalFileSystem::directorySize(source, scan); LocalFileSystem::directorySize(source, scan);
// Items going to their own folders are not checked up front.
if ((request.operation == Operation::Copy if ((request.operation == Operation::Copy
|| request.operation == Operation::Move) || request.operation == Operation::Move)
&& request.targets.isEmpty()
&& takesSpace(source, FileSystemUrl(request.destination).localPath(), && takesSpace(source, FileSystemUrl(request.destination).localPath(),
request.operation == Operation::Move)) request.operation == Operation::Move))
required += scan.allocated; required += scan.allocated;
@ -172,10 +184,16 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx,
case Operation::Move: case Operation::Move:
case Operation::Link: case Operation::Link:
case Operation::Rename: { case Operation::Rename: {
const auto destination = FileSystemUrl(request.destination).localPath(); QString target;
const auto target = request.operation == Operation::Rename if (!request.targets.isEmpty()) {
? destination target = FileSystemUrl(request.targets.value(sourceIndex)).localPath();
: QDir(destination).filePath(url.fileName()); } 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 const auto path = request.operation == Operation::Link
? LocalFileSystem::link(source, target, request.conflict, ctx) ? LocalFileSystem::link(source, target, request.conflict, ctx)
: request.operation == Operation::Copy : request.operation == Operation::Copy
@ -202,8 +220,10 @@ static TaskResult execute(const TaskRequest &request, OperationContext &ctx,
} }
result.completedSources.append(url); result.completedSources.append(url);
if (!output.isEmpty()) if (!output.isEmpty()) {
result.outputs.append(output); result.outputs.append(output);
result.outputSources.append(url);
}
if (info.totalsKnown && sourceIndex < weights.size()) { if (info.totalsKnown && sourceIndex < weights.size()) {
creditedBytes += weights[sourceIndex].first; creditedBytes += weights[sourceIndex].first;
@ -406,30 +426,56 @@ TaskController::~TaskController()
Task *TaskController::submit(const TaskRequest &request) 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<TaskRequest> 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 // A fresh operation forks the history: what was undone before it can no
// longer be redone. Undo and redo themselves keep the other stack. // 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(); m_redo.clear();
emit historyChanged(); emit historyChanged();
} }
auto *task = new Task(request, this); auto *task = new Task(request, this);
connect(task, &Task::result, this, [this, request](Task *task) { connect(task, &Task::result, this, [this, request, history](Task *task) {
const auto &result = task->outcome(); record(request, task->outcome(), history);
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();
}
emit taskFinished(task); emit taskFinished(task);
task->deleteLater(); task->deleteLater();
}); });
@ -439,6 +485,52 @@ Task *TaskController::submit(const TaskRequest &request, bool keepRedo)
return task; 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() Task *TaskController::undo()
{ {
if (m_undo.isEmpty()) if (m_undo.isEmpty())
@ -446,28 +538,9 @@ Task *TaskController::undo()
const auto request = m_undo.takeLast(); const auto request = m_undo.takeLast();
emit historyChanged(); emit historyChanged();
auto *task = submit(request, true); return submit(request, History::Undo);
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;
} }
// Redoing a trash goes through the normal path, which records its undo again.
Task *TaskController::redo() Task *TaskController::redo()
{ {
if (m_redo.isEmpty()) if (m_redo.isEmpty())
@ -475,22 +548,7 @@ Task *TaskController::redo()
const auto request = m_redo.takeLast(); const auto request = m_redo.takeLast();
emit historyChanged(); emit historyChanged();
auto *task = submit(request, true); return submit(request, History::Redo);
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;
} }
Task *copy(const QList<QUrl> &sources, const QUrl &destination) Task *copy(const QList<QUrl> &sources, const QUrl &destination)

@ -32,12 +32,16 @@ struct TaskRequest
QList<QUrl> sources; QList<QUrl> sources;
QUrl destination; QUrl destination;
ConflictPolicy conflict = ConflictPolicy::Fail; ConflictPolicy conflict = ConflictPolicy::Fail;
// When set, Copy, Move and Link put each source at its own path instead of into destination.
QList<QUrl> targets;
}; };
struct TaskResult struct TaskResult
{ {
Error error; Error error;
QList<QUrl> outputs; QList<QUrl> outputs;
// The source each output came from; empty for Mkdir.
QList<QUrl> outputSources;
QList<QUrl> completedSources; QList<QUrl> completedSources;
quint64 bytes = 0; quint64 bytes = 0;
quint64 files = 0; quint64 files = 0;
@ -177,9 +181,16 @@ signals:
void historyChanged(); void historyChanged();
private: 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; static constexpr int kMaxHistory = 50;
QList<TaskRequest> m_undo; QList<TaskRequest> m_undo;

@ -668,7 +668,7 @@ private slots:
QCOMPARE(errorCode([&] { QCOMPARE(errorCode([&] {
LocalFileSystem::restore(orphan.url(), ConflictPolicy::Fail, ctx); LocalFileSystem::restore(orphan.url(), ConflictPolicy::Fail, ctx);
}), }),
EACCES); ENOENT);
QVERIFY(QDir().mkpath(volume + "/docs")); QVERIFY(QDir().mkpath(volume + "/docs"));
QCOMPARE(LocalFileSystem::restore(old.url(), ConflictPolicy::Fail, ctx), QCOMPARE(LocalFileSystem::restore(old.url(), ConflictPolicy::Fail, ctx),
@ -1495,6 +1495,141 @@ private slots:
QVERIFY(!controller.isRedoAvailable()); 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<Task *>()->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() void crossDeviceMove()
{ {
const auto root = qEnvironmentVariable("CUTEFISH_IO_CROSS_DEVICE_ROOT"); const auto root = qEnvironmentVariable("CUTEFISH_IO_CROSS_DEVICE_ROOT");

@ -260,7 +260,7 @@ QUrl LocalFileSystem::restore(const QUrl &url, ConflictPolicy policy, OperationC
const auto info = infoPath(trash, name); const auto info = infoPath(trash, name);
TrashInfo details; TrashInfo details;
if (!readTrashInfo(trash, name, &details)) if (!readTrashInfo(trash, name, &details))
throw Failure(EACCES, info); throw Failure(QFileInfo::exists(info) ? EACCES : ENOENT, info);
if (details.path.isEmpty()) if (details.path.isEmpty())
throw Failure(EINVAL, info); throw Failure(EINVAL, info);

Loading…
Cancel
Save