From cb12193e332c07aceba9b91dfae3abdf0266e2ae Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:29:12 +0200 Subject: [PATCH 001/114] Added conflict and failure id types --- ids/include/ids/ids.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ids/include/ids/ids.hpp b/ids/include/ids/ids.hpp index 19875eab..fd31c22b 100644 --- a/ids/include/ids/ids.hpp +++ b/ids/include/ids/ids.hpp @@ -11,4 +11,6 @@ DEFINE_ID_TYPE(OperationId) DEFINE_ID_TYPE(InstanceId) DEFINE_ID_TYPE(WatchId) DEFINE_ID_TYPE(SyncSessionId) -DEFINE_ID_TYPE(QuestionId) \ No newline at end of file +DEFINE_ID_TYPE(QuestionId) +DEFINE_ID_TYPE(ConflictId) +DEFINE_ID_TYPE(FailureId) From aa6763469283b1fc4b88a5e7b19ebb1fa77c2aa8 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:32:41 +0200 Subject: [PATCH 002/114] Added the shared types of conflicts, failures, progress, stops and orphans - TrackedEntry carries its local path, whether it is a directory, its stop and its instance - WatchEndReason gained Overlap --- .../source/backend/file_tracking/tracker.cpp | 4 ++ .../file_tracking/ended_watches.hpp | 4 +- .../file_tracking/orphaned_copy.hpp | 47 ++++++++++++++ .../file_tracking/tracked_entry.hpp | 15 ++++- .../file_tracking/upload_failure.hpp | 52 ++++++++++++++++ .../file_tracking/watch_conflict.hpp | 46 ++++++++++++++ .../file_tracking/watch_progress.hpp | 61 +++++++++++++++++++ .../shared_data/file_tracking/watch_stop.hpp | 39 ++++++++++++ 8 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 shared_data/include/shared_data/file_tracking/orphaned_copy.hpp create mode 100644 shared_data/include/shared_data/file_tracking/upload_failure.hpp create mode 100644 shared_data/include/shared_data/file_tracking/watch_conflict.hpp create mode 100644 shared_data/include/shared_data/file_tracking/watch_progress.hpp create mode 100644 shared_data/include/shared_data/file_tracking/watch_stop.hpp diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 0d220796..d1e67de4 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -242,6 +242,10 @@ namespace FileTracking .options = entry.options, .uploading = entry.shownUploading, .downloadedCopy = entry.downloadedCopy() != nullptr, + .localPath = entry.localPath, + .isDirectory = entry.downloadedCopy() == nullptr && isDirectoryLocally(entry.localPath), + .stop = std::nullopt, + .instanceId = entry.downloadedCopy() ? std::optional{entry.downloadedCopy()->instanceId} : std::nullopt, }; } diff --git a/shared_data/include/shared_data/file_tracking/ended_watches.hpp b/shared_data/include/shared_data/file_tracking/ended_watches.hpp index f1422cfe..6e7c70ba 100644 --- a/shared_data/include/shared_data/file_tracking/ended_watches.hpp +++ b/shared_data/include/shared_data/file_tracking/ended_watches.hpp @@ -11,9 +11,9 @@ namespace SharedData /** * @brief Why watches ended that the user did not stop. CopyGone: the downloaded copy was deleted or renamed away * and did not come back. ConnectionLost: the session lost its connection, either side may change until it - * is back. + * is back. Overlap: the item moved onto the server path of another watch. */ - BOOST_DEFINE_ENUM_CLASS(WatchEndReason, CopyGone, ConnectionLost) + BOOST_DEFINE_ENUM_CLASS(WatchEndReason, CopyGone, ConnectionLost, Overlap) /** * @brief Watches of the session's tracker that ended for the same reason. diff --git a/shared_data/include/shared_data/file_tracking/orphaned_copy.hpp b/shared_data/include/shared_data/file_tracking/orphaned_copy.hpp new file mode 100644 index 00000000..51dc564a --- /dev/null +++ b/shared_data/include/shared_data/file_tracking/orphaned_copy.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace SharedData +{ + /** + * @brief An instance directory of a downloaded copy that no live process holds any more. + */ + struct OrphanedCopy + { + Ids::InstanceId instanceId; + /** @brief The name of its first content entry. */ + std::string name; + /** @brief The instance directory. */ + Paths::LocalPath directory; + /** @brief Its first content entry, nothing when it holds none. */ + std::optional copyPath{}; + /** @brief Where the copy came from, nothing for an instance that did not record it. */ + std::optional remotePath{}; + /** @brief The size of its content. */ + std::uint64_t sizeBytes{0}; + /** @brief When the instance was created, seconds since the Unix epoch. */ + std::optional downloadedAt{}; + /** @brief When the instance ended, seconds since the Unix epoch. */ + std::optional endedAt{}; + }; + BOOST_DESCRIBE_STRUCT( + OrphanedCopy, + (), + (instanceId, name, directory, copyPath, remotePath, sizeBytes, downloadedAt, endedAt) + ) + + struct OrphanedCopies + { + std::vector copies{}; + }; + BOOST_DESCRIBE_STRUCT(OrphanedCopies, (), (copies)) +} diff --git a/shared_data/include/shared_data/file_tracking/tracked_entry.hpp b/shared_data/include/shared_data/file_tracking/tracked_entry.hpp index 6ac19584..b4c9c830 100644 --- a/shared_data/include/shared_data/file_tracking/tracked_entry.hpp +++ b/shared_data/include/shared_data/file_tracking/tracked_entry.hpp @@ -3,9 +3,11 @@ #include #include #include +#include #include #include +#include #include namespace SharedData @@ -28,6 +30,17 @@ namespace SharedData * reach the server, the options for those stay off. */ bool downloadedCopy{false}; + /** @brief The watched local file or directory, for a downloaded copy its file in the instance directory. */ + Paths::LocalPath localPath{}; + bool isDirectory{false}; + /** @brief Set once the watch stopped, the entry stays until it is removed. */ + std::optional stop{}; + /** @brief The instance directory of a downloaded copy. */ + std::optional instanceId{}; }; - BOOST_DESCRIBE_STRUCT(TrackedEntry, (), (watchId, name, remotePath, options, uploading, downloadedCopy)) + BOOST_DESCRIBE_STRUCT( + TrackedEntry, + (), + (watchId, name, remotePath, options, uploading, downloadedCopy, localPath, isDirectory, stop, instanceId) + ) } diff --git a/shared_data/include/shared_data/file_tracking/upload_failure.hpp b/shared_data/include/shared_data/file_tracking/upload_failure.hpp new file mode 100644 index 00000000..8394cee0 --- /dev/null +++ b/shared_data/include/shared_data/file_tracking/upload_failure.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace SharedData +{ + /** + * @brief Why the upload of a watched path failed. NotQueued: the operation queue refused it. + */ + BOOST_DEFINE_ENUM_CLASS( + UploadFailureReason, + LockedLocally, + PermissionDeniedLocally, + UnreadableLocally, + PermissionDeniedOnServer, + NoSpaceOnServer, + ServerRefused, + ConnectionProblem, + NotQueued, + Unknown + ) + + /** + * @brief A path of a watch whose upload failed, it waits for Retry or Dismiss. Nothing retries it on its own. + */ + struct UploadFailure + { + Ids::FailureId failureId; + Ids::WatchId watchId; + Paths::LocalPath localPath; + Paths::RemotePath remotePath; + UploadFailureReason reason{UploadFailureReason::Unknown}; + /** @brief The server's or the system's message, untranslated. */ + std::optional detail{}; + /** @brief When it failed last, seconds since the Unix epoch. */ + std::uint64_t failedAt{0}; + /** @brief How often the upload of the path failed since it last went through. */ + std::uint64_t attempts{1}; + }; + BOOST_DESCRIBE_STRUCT( + UploadFailure, + (), + (failureId, watchId, localPath, remotePath, reason, detail, failedAt, attempts) + ) +} diff --git a/shared_data/include/shared_data/file_tracking/watch_conflict.hpp b/shared_data/include/shared_data/file_tracking/watch_conflict.hpp new file mode 100644 index 00000000..896a30c7 --- /dev/null +++ b/shared_data/include/shared_data/file_tracking/watch_conflict.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace SharedData +{ + /** + * @brief What the server did to the copy a local change would replace, since it was opened or last uploaded. + */ + BOOST_DEFINE_ENUM_CLASS(ConflictKind, ChangedOnServer, DeletedOnServer, CreatedOnServer) + + /** + * @brief The user's answer to a WatchConflict. ReplaceOnServer uploads the local version, KeepServers leaves the + * server's and asks again on the next save. + */ + BOOST_DEFINE_ENUM_CLASS(ConflictResolution, ReplaceOnServer, KeepServers) + + /** + * @brief A local change of a watch that waits for the user, because the server's copy changed meanwhile. + */ + struct WatchConflict + { + Ids::ConflictId conflictId; + Ids::WatchId watchId; + ConflictKind kind{ConflictKind::ChangedOnServer}; + Paths::LocalPath localPath; + Paths::RemotePath remotePath; + /** @brief The modification time of the server's copy, seconds since the Unix epoch. */ + std::optional serverModifiedAt{}; + /** @brief The modification time of the local file, seconds since the Unix epoch. */ + std::optional localModifiedAt{}; + /** @brief Seconds since the Unix epoch. */ + std::uint64_t raisedAt{0}; + }; + BOOST_DESCRIBE_STRUCT( + WatchConflict, + (), + (conflictId, watchId, kind, localPath, remotePath, serverModifiedAt, localModifiedAt, raisedAt) + ) +} diff --git a/shared_data/include/shared_data/file_tracking/watch_progress.hpp b/shared_data/include/shared_data/file_tracking/watch_progress.hpp new file mode 100644 index 00000000..c44b38cf --- /dev/null +++ b/shared_data/include/shared_data/file_tracking/watch_progress.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace SharedData +{ + /** + * @brief A file of a watch on its way to the server. + */ + struct WatchFileProgress + { + Paths::LocalPath path; + std::uint64_t bytes{0}; + std::uint64_t totalBytes{0}; + }; + BOOST_DESCRIBE_STRUCT(WatchFileProgress, (), (path, bytes, totalBytes)) + + /** + * @brief The last burst of changes of a watch that ended. + */ + struct WatchSync + { + /** @brief Seconds since the Unix epoch. */ + std::uint64_t endedAt{0}; + std::uint64_t changes{0}; + std::uint64_t bytes{0}; + }; + BOOST_DESCRIBE_STRUCT(WatchSync, (), (endedAt, changes, bytes)) + + /** + * @brief How far one watch is with mirroring its changes to the server. + */ + struct WatchProgress + { + Ids::WatchId watchId; + /** @brief Changes on their way: waiting for the check, an answer or the queue, or running. */ + std::uint64_t changesPending{0}; + /** @brief Changes of the current burst that ended. */ + std::uint64_t changesDone{0}; + /** @brief Pending changes that have not started on the server yet. */ + std::uint64_t changesWaiting{0}; + /** @brief Pending changes that wait for the user's answer to a conflict. */ + std::uint64_t waitingForAnswer{0}; + std::uint64_t bytesDone{0}; + std::uint64_t bytesTotal{0}; + std::vector inFlight{}; + std::optional lastSync{}; + }; + BOOST_DESCRIBE_STRUCT( + WatchProgress, + (), + (watchId, changesPending, changesDone, changesWaiting, waitingForAnswer, bytesDone, bytesTotal, inFlight, lastSync) + ) +} diff --git a/shared_data/include/shared_data/file_tracking/watch_stop.hpp b/shared_data/include/shared_data/file_tracking/watch_stop.hpp new file mode 100644 index 00000000..4dea4fbc --- /dev/null +++ b/shared_data/include/shared_data/file_tracking/watch_stop.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace SharedData +{ + /** + * @brief Why a watch stopped. StoppedByUser: the user stopped it. Overlap: its item moved onto the server path of + * another watch. CopyGone: the downloaded copy was deleted or renamed away and did not come back. + * ConnectionLost: the session lost its connection. + */ + BOOST_DEFINE_ENUM_CLASS(WatchStopReason, StoppedByUser, Overlap, CopyGone, ConnectionLost) + + /** + * @brief How a watch stopped, its entry stays listed until it is removed. + */ + struct WatchStop + { + WatchStopReason reason{WatchStopReason::StoppedByUser}; + /** @brief Seconds since the Unix epoch. */ + std::uint64_t stoppedAt{0}; + /** @brief Overlap: the watch whose server path the item moved onto. */ + std::optional otherWatchId{}; + /** @brief Overlap: the name of that watch's item. */ + std::optional otherWatchName{}; + /** @brief Overlap: where the item moved to locally. */ + std::optional movedTo{}; + /** @brief Overlap: where its server copy would have moved to. */ + std::optional wouldGoTo{}; + }; + BOOST_DESCRIBE_STRUCT(WatchStop, (), (reason, stoppedAt, otherWatchId, otherWatchName, movedTo, wouldGoTo)) +} From 3b0d15051d353c41313b10e33ef55e15abd5c327 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:32:41 +0200 Subject: [PATCH 003/114] Told the local cause and the SFTP code of failed operations and entries --- .../file_operations/local_file_problem.hpp | 12 ++++++++++++ .../file_operations/operation_completed.hpp | 7 ++++++- .../shared_data/file_operations/operation_error.hpp | 5 ++++- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 shared_data/include/shared_data/file_operations/local_file_problem.hpp diff --git a/shared_data/include/shared_data/file_operations/local_file_problem.hpp b/shared_data/include/shared_data/file_operations/local_file_problem.hpp new file mode 100644 index 00000000..7d80828e --- /dev/null +++ b/shared_data/include/shared_data/file_operations/local_file_problem.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace SharedData +{ + /** + * @brief Why a local file could not be read or written. Locked: another program holds it. + */ + BOOST_DEFINE_ENUM_CLASS(LocalFileProblem, Locked, PermissionDenied, NotFound, NoSpace, Other) +} diff --git a/shared_data/include/shared_data/file_operations/operation_completed.hpp b/shared_data/include/shared_data/file_operations/operation_completed.hpp index 0961c9d2..10b77ca8 100644 --- a/shared_data/include/shared_data/file_operations/operation_completed.hpp +++ b/shared_data/include/shared_data/file_operations/operation_completed.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -26,8 +27,12 @@ namespace SharedData { std::uint32_t index{0}; OperationErrorType type{OperationErrorType::UnknownError}; + /** @brief Why the local file could not be read, for an error on the local side. */ + std::optional localProblem{}; + /** @brief The server's SFTP status code, for an error the server answered with. */ + std::optional sftpErrorCode{}; }; - BOOST_DESCRIBE_STRUCT(IndexedFailure, (), (index, type)) + BOOST_DESCRIBE_STRUCT(IndexedFailure, (), (index, type, localProblem, sftpErrorCode)) struct OperationCompleted { diff --git a/shared_data/include/shared_data/file_operations/operation_error.hpp b/shared_data/include/shared_data/file_operations/operation_error.hpp index a0315322..530bed4c 100644 --- a/shared_data/include/shared_data/file_operations/operation_error.hpp +++ b/shared_data/include/shared_data/file_operations/operation_error.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -55,6 +56,8 @@ namespace SharedData OperationErrorType type; std::optional sftpError = std::nullopt; std::optional extraInfo = std::nullopt; + /** @brief Why the local file could not be read or written, for an error on the local side. */ + std::optional localProblem = std::nullopt; std::string toString() const { @@ -73,5 +76,5 @@ namespace SharedData return enumString; } }; - BOOST_DESCRIBE_STRUCT(OperationError, (), (type, sftpError)) + BOOST_DESCRIBE_STRUCT(OperationError, (), (type, sftpError, localProblem)) } \ No newline at end of file From baf093e63c6fbbea4daccca0c077e30efc4b2e6d Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:40:46 +0200 Subject: [PATCH 004/114] Gave the tracker view conflicts, failures, watch progress and orphaned copies - FrontendTrackerView emits onConflict, onConflictRemoved, onFailure, onFailureRemoved, onWatchProgress and onOrphanedCopies --- .../file_tracking/frontend_tracker_view.hpp | 10 ++++- .../backend/file_tracking/tracker_view.hpp | 40 +++++++++++++++++-- .../file_tracking/frontend_tracker_view.cpp | 30 ++++++++++++++ backend/test/backend/test_tracker.hpp | 40 +++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/backend/include/backend/file_tracking/frontend_tracker_view.hpp b/backend/include/backend/file_tracking/frontend_tracker_view.hpp index 929a1ab9..ad2f24a9 100644 --- a/backend/include/backend/file_tracking/frontend_tracker_view.hpp +++ b/backend/include/backend/file_tracking/frontend_tracker_view.hpp @@ -17,7 +17,9 @@ namespace FileTracking * @brief Shows a Tracker in the file tracking panel of its session, which listens to * @c "FileTracker::::": onEntry (SharedData::TrackedEntry), onEntryRemoved (watch id), * onEntriesEnded (SharedData::EndedWatches), onQuestion (SharedData::ServerChangedQuestion), - * onOrphanedCount (count) and onProgress (SharedData::TrackerProgress). + * onOrphanedCount (count), onProgress (SharedData::TrackerProgress), onConflict (SharedData::WatchConflict), + * onConflictRemoved (conflict id), onFailure (SharedData::UploadFailure), onFailureRemoved (failure id), + * onWatchProgress (SharedData::WatchProgress) and onOrphanedCopies (SharedData::OrphanedCopies). */ class FrontendTrackerView : public TrackerView { @@ -30,6 +32,12 @@ namespace FileTracking void ask(SharedData::ServerChangedQuestion const& question) override; void showOrphanedCount(std::size_t count) override; void showProgress(SharedData::TrackerProgress const& progress) override; + void showConflict(SharedData::WatchConflict const& conflict) override; + void removeConflict(Ids::ConflictId const& conflictId) override; + void showFailure(SharedData::UploadFailure const& failure) override; + void removeFailure(Ids::FailureId const& failureId) override; + void showWatchProgress(SharedData::WatchProgress const& progress) override; + void showOrphanedCopies(SharedData::OrphanedCopies const& copies) override; private: std::string eventName(std::string_view event) const; diff --git a/backend/include/backend/file_tracking/tracker_view.hpp b/backend/include/backend/file_tracking/tracker_view.hpp index 01c53cc9..b8e7b506 100644 --- a/backend/include/backend/file_tracking/tracker_view.hpp +++ b/backend/include/backend/file_tracking/tracker_view.hpp @@ -2,17 +2,21 @@ #include #include +#include #include #include #include +#include +#include +#include #include namespace FileTracking { /** - * @brief What the user sees of a Tracker: its entries, its questions, its progress and the orphaned instances. Called on the - * strand of the tracker. + * @brief What the user sees of a Tracker: its entries, their conflicts, failures and progress, and the orphaned + * copies. Called on the strand of the tracker. */ class TrackerView { @@ -25,7 +29,7 @@ namespace FileTracking TrackerView& operator=(TrackerView&&) = delete; /** - * @brief Shows @p entry, new or changed. + * @brief Shows @p entry, new or changed, a stopped one too. */ virtual void showEntry(SharedData::TrackedEntry const& entry) = 0; @@ -51,5 +55,35 @@ namespace FileTracking * @brief Shows how far the tracker is with the changes on their way, at most a few times a second. */ virtual void showProgress(SharedData::TrackerProgress const& progress) = 0; + + /** + * @brief Shows @p conflict, a local change that waits for the user's answer. + */ + virtual void showConflict(SharedData::WatchConflict const& conflict) = 0; + + /** + * @brief The conflict @p conflictId was answered or dropped with its watch. + */ + virtual void removeConflict(Ids::ConflictId const& conflictId) = 0; + + /** + * @brief Shows @p failure, new or updated. + */ + virtual void showFailure(SharedData::UploadFailure const& failure) = 0; + + /** + * @brief The failure @p failureId was retried, dismissed, went through or was dropped with its watch. + */ + virtual void removeFailure(Ids::FailureId const& failureId) = 0; + + /** + * @brief Shows how far the watch of @p progress is, at most five times a second per watch. + */ + virtual void showWatchProgress(SharedData::WatchProgress const& progress) = 0; + + /** + * @brief Shows the orphaned copies, all of them, replacing what was shown before. + */ + virtual void showOrphanedCopies(SharedData::OrphanedCopies const& copies) = 0; }; } diff --git a/backend/source/backend/file_tracking/frontend_tracker_view.cpp b/backend/source/backend/file_tracking/frontend_tracker_view.cpp index a59a7445..68a1ab44 100644 --- a/backend/source/backend/file_tracking/frontend_tracker_view.cpp +++ b/backend/source/backend/file_tracking/frontend_tracker_view.cpp @@ -43,6 +43,36 @@ namespace FileTracking RpcHelper::callRemoteSafely(*hub_, eventName("onProgress"), progress); } + void FrontendTrackerView::showConflict(SharedData::WatchConflict const& conflict) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onConflict"), conflict); + } + + void FrontendTrackerView::removeConflict(Ids::ConflictId const& conflictId) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onConflictRemoved"), conflictId.value()); + } + + void FrontendTrackerView::showFailure(SharedData::UploadFailure const& failure) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onFailure"), failure); + } + + void FrontendTrackerView::removeFailure(Ids::FailureId const& failureId) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onFailureRemoved"), failureId.value()); + } + + void FrontendTrackerView::showWatchProgress(SharedData::WatchProgress const& progress) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onWatchProgress"), progress); + } + + void FrontendTrackerView::showOrphanedCopies(SharedData::OrphanedCopies const& copies) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onOrphanedCopies"), copies); + } + std::string FrontendTrackerView::eventName(std::string_view event) const { return fmt::format("FileTracker::{}::{}", sessionId_.value(), event); diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index c2da0f77..16aaf0ef 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -208,12 +208,52 @@ namespace Test progress.push_back(shown); } + void showConflict(SharedData::WatchConflict const& conflict) override + { + conflicts.insert_or_assign(conflict.conflictId.value(), conflict); + } + + void removeConflict(Ids::ConflictId const& conflictId) override + { + conflicts.erase(conflictId.value()); + removedConflicts.push_back(conflictId.value()); + } + + void showFailure(SharedData::UploadFailure const& failure) override + { + failures.insert_or_assign(failure.failureId.value(), failure); + } + + void removeFailure(Ids::FailureId const& failureId) override + { + failures.erase(failureId.value()); + removedFailures.push_back(failureId.value()); + } + + void showWatchProgress(SharedData::WatchProgress const& shown) override + { + watchProgress.push_back(shown); + } + + void showOrphanedCopies(SharedData::OrphanedCopies const& copies) override + { + orphanedCopies.push_back(copies); + } + std::map entries{}; std::vector removed{}; std::vector ended{}; std::vector questions{}; std::vector orphanedCounts{}; std::vector progress{}; + /** @brief The conflicts shown and not removed, by id. */ + std::map conflicts{}; + std::vector removedConflicts{}; + /** @brief The failures shown and not removed, by id. */ + std::map failures{}; + std::vector removedFailures{}; + std::vector watchProgress{}; + std::vector orphanedCopies{}; }; using Opened = std::expected; From fd27ae69cefabde40b284b74d6ce1cb5048c507b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:40:59 +0200 Subject: [PATCH 005/114] Gave uploads a progress callback and told why they failed - UploadEnd carries the failure and the entries a directory upload left out - BatchingRemote hands each upload's progress to the inner remote, a bulk's by entry index - The tracker and the session's remote pass and take empty callbacks for now --- .../backend/file_tracking/batching_remote.hpp | 21 +++++-- .../backend/file_tracking/tracker_remote.hpp | 57 +++++++++++++++++-- .../backend/file_tracking/batching_remote.cpp | 34 +++++++---- .../source/backend/file_tracking/tracker.cpp | 3 +- .../source/backend/session_file_tracking.cpp | 13 ++++- backend/test/backend/test_batching_remote.hpp | 33 ++++++++--- backend/test/backend/test_tracker.hpp | 9 ++- 7 files changed, 136 insertions(+), 34 deletions(-) diff --git a/backend/include/backend/file_tracking/batching_remote.hpp b/backend/include/backend/file_tracking/batching_remote.hpp index 14df764c..b6930b55 100644 --- a/backend/include/backend/file_tracking/batching_remote.hpp +++ b/backend/include/backend/file_tracking/batching_remote.hpp @@ -33,12 +33,22 @@ namespace FileTracking */ using OnUploadEntryEnded = std::function; + /** + * @brief Called on the strand while an upload of a bulk is on its way, with the entry's index, never after the + * bulk ended. + */ + using OnUploadEntryProgress = std::function; + /** * @brief Queues the uploads of @p requests, regular files that may create the directories above their - * destination, as one operation. + * destination, as one operation. The index of an entry is that of its request. @p onProgress may be + * empty. */ - virtual std::expected - uploadAll(std::vector const& requests, OnUploadEntryEnded onEnded) = 0; + virtual std::expected uploadAll( + std::vector const& requests, + OnUploadEntryEnded onEnded, + OnUploadEntryProgress onProgress + ) = 0; /** * @brief Queues the deletes of @p paths as one operation, a directory only when it is empty. @@ -95,9 +105,10 @@ namespace FileTracking /** * @brief Gathers @p request into the next batch. It goes on its own when it creates no missing directories, * refreshes the listing, is no regular file, is a large one, continues a partial file or is alone in - * its batch. + * its batch. @p onProgress hears what the inner remote tells of it. */ - std::expected upload(UploadRequest const& request, OnUploadEnded onEnded) override; + std::expected + upload(UploadRequest const& request, OnUploadEnded onEnded, OnUploadProgress onProgress) override; /** * @brief An upload still gathered ends as cancelled without being sent, one on its way is stopped by the inner diff --git a/backend/include/backend/file_tracking/tracker_remote.hpp b/backend/include/backend/file_tracking/tracker_remote.hpp index 2dbc545c..239e4e9e 100644 --- a/backend/include/backend/file_tracking/tracker_remote.hpp +++ b/backend/include/backend/file_tracking/tracker_remote.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -65,6 +67,25 @@ namespace FileTracking std::optional state{}; }; + /** + * @brief Why an upload or an entry of it failed. + */ + struct UploadFailureInfo + { + SharedData::UploadFailureReason reason{SharedData::UploadFailureReason::Unknown}; + /** @brief The server's or the system's message, untranslated. */ + std::optional detail{}; + }; + + /** + * @brief An entry below a directory that its upload left out, the upload may still complete. + */ + struct FailedUploadEntry + { + Paths::LocalPath path; + UploadFailureInfo failure{}; + }; + /** * @brief How a queued upload ended. */ @@ -74,14 +95,36 @@ namespace FileTracking /** @brief The destination no longer held the request's expectedDestination, nothing replaced it. */ bool changedOnServer{false}; /** - * @brief What the server has at the destination after a completed upload of anything but a directory, nothing - * when it has nothing there or could not be asked. + * @brief What the server has at the destination after a completed upload of anything but a directory, and + * what it has instead of the expected state when changedOnServer. Nothing when it has nothing there or + * could not be asked. */ std::optional destinationState{}; /** * @brief The regular files a completed upload wrote to the server, those below a directory too. */ std::vector sentFiles{}; + /** @brief Why a failed upload failed, nothing for one that completed, was cancelled or changedOnServer. */ + std::optional failure{}; + /** @brief The entries a directory upload left out. */ + std::vector failedEntries{}; + }; + + /** + * @brief How far a queued upload is. + */ + struct UploadProgress + { + std::uint64_t bytesSent{0}; + std::uint64_t bytesTotal{0}; + /** @brief Its files on their way right now. */ + std::vector inFlight{}; + /** @brief Its files that are done, those below a directory too. */ + std::uint64_t filesDone{0}; + /** @brief Its files, those below a directory too, as far as they are known. */ + std::uint64_t fileCount{0}; + /** @brief Every byte of it went to the server, its end follows once its operation ended. */ + bool transferred{false}; }; /** @@ -101,6 +144,11 @@ namespace FileTracking */ using OnUploadEnded = std::function; + /** + * @brief Called on the strand while a queued upload is on its way, never after it ended. + */ + using OnUploadProgress = std::function; + TrackerRemote() = default; virtual ~TrackerRemote() = default; TrackerRemote(TrackerRemote const&) = delete; @@ -131,9 +179,10 @@ namespace FileTracking /** * @brief Queues the upload, a source that is gone by the time it runs ends it without a transfer: its - * deletion is mirrored apart. + * deletion is mirrored apart. @p onProgress hears how far it is, it may be empty. */ - virtual std::expected upload(UploadRequest const& request, OnUploadEnded onEnded) = 0; + virtual std::expected + upload(UploadRequest const& request, OnUploadEnded onEnded, OnUploadProgress onProgress) = 0; /** * @brief Stops the upload to @p destination that is on its way. It still ends once, never before this call diff --git a/backend/source/backend/file_tracking/batching_remote.cpp b/backend/source/backend/file_tracking/batching_remote.cpp index 348809a4..5f27dd4b 100644 --- a/backend/source/backend/file_tracking/batching_remote.cpp +++ b/backend/source/backend/file_tracking/batching_remote.cpp @@ -19,6 +19,7 @@ namespace FileTracking { UploadRequest request; TrackerRemote::OnUploadEnded onEnded; + TrackerRemote::OnUploadProgress onProgress; }; struct GatheredRemoval @@ -58,15 +59,15 @@ namespace FileTracking } /** - * @brief Hands the end of each entry of a bulk to the callback it was gathered with. + * @brief Hands what a bulk tells of each entry, its end or its progress, to the callback it was gathered with. */ template - auto entryEndsOf(std::vector onEnded) + auto perEntry(std::vector callbacks) { - return [onEnded = std::move(onEnded)](std::size_t index, auto const& ended) + return [callbacks = std::move(callbacks)](std::size_t index, auto const& told) { - if (index < onEnded.size() && onEnded[index]) - onEnded[index](ended); + if (index < callbacks.size() && callbacks[index]) + callbacks[index](told); }; } @@ -139,6 +140,7 @@ namespace FileTracking { std::vector bulk; std::vector bulkEnds; + std::vector bulkProgress; std::vector single; for (auto& upload : gathered) { @@ -146,16 +148,22 @@ namespace FileTracking { bulk.push_back(std::move(upload.request)); bulkEnds.push_back(std::move(upload.onEnded)); + bulkProgress.push_back(std::move(upload.onProgress)); } else single.push_back(std::move(upload)); } if (bulk.size() == 1) - single.push_back({.request = std::move(bulk.front()), .onEnded = std::move(bulkEnds.front())}); + single.push_back({ + .request = std::move(bulk.front()), + .onEnded = std::move(bulkEnds.front()), + .onProgress = std::move(bulkProgress.front()), + }); else if (!bulk.empty()) { Log::info("FileTracking: uploading {} changed files as one operation", bulk.size()); - if (const auto queued = inner->uploadAll(bulk, entryEndsOf(bulkEnds)); !queued) + if (const auto queued = inner->uploadAll(bulk, perEntry(bulkEnds), perEntry(std::move(bulkProgress))); + !queued) { Log::warn("FileTracking: did not upload {} changed files: {}", bulk.size(), queued.error()); endAll(bulkEnds, failedUpload); @@ -163,7 +171,8 @@ namespace FileTracking } for (auto& upload : single) { - if (const auto queued = inner->upload(upload.request, upload.onEnded); !queued) + if (const auto queued = inner->upload(upload.request, upload.onEnded, std::move(upload.onProgress)); + !queued) { // A temporary file an editor already renamed or removed ends up here. Log::warn("FileTracking: did not upload '{}': {}", upload.request.source.toUtf8(), queued.error()); @@ -196,7 +205,7 @@ namespace FileTracking ends.push_back(std::move(removal.onEnded)); } Log::info("FileTracking: deleting {} paths as one operation", paths.size()); - if (const auto queued = inner->removeAll(paths, entryEndsOf(ends)); !queued) + if (const auto queued = inner->removeAll(paths, perEntry(ends)); !queued) { Log::error("FileTracking: did not delete {} paths: {}", paths.size(), queued.error()); endAll(ends, SharedData::OperationCompletionReason::Failed); @@ -260,9 +269,12 @@ namespace FileTracking return impl_->inner->download(source, destination, sizeBytes, std::move(onEnded)); } - std::expected BatchingRemote::upload(UploadRequest const& request, OnUploadEnded onEnded) + std::expected + BatchingRemote::upload(UploadRequest const& request, OnUploadEnded onEnded, OnUploadProgress onProgress) { - impl_->uploads.push_back({.request = request, .onEnded = std::move(onEnded)}); + impl_->uploads.push_back( + {.request = request, .onEnded = std::move(onEnded), .onProgress = std::move(onProgress)} + ); impl_->gathered(); return {}; } diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index d1e67de4..0ae4b73c 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -1963,7 +1963,8 @@ namespace FileTracking { if (const auto self = weak.lock()) self->onUploadEnded(upload, ended); - } + }, + {} ); if (queued) return; diff --git a/backend/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index 81bc26f0..01d7001b 100644 --- a/backend/source/backend/session_file_tracking.cpp +++ b/backend/source/backend/session_file_tracking.cpp @@ -230,7 +230,11 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote ); } - std::expected upload(FileTracking::UploadRequest const& request, OnUploadEnded onEnded) override + std::expected upload( + FileTracking::UploadRequest const& request, + OnUploadEnded onEnded, + [[maybe_unused]] OnUploadProgress onProgress + ) override { const bool isDirectory = isDirectoryLocally(request.source); const auto sent = std::make_shared(); @@ -346,8 +350,11 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote ); } - std::expected - uploadAll(std::vector const& requests, OnUploadEntryEnded onEnded) override + std::expected uploadAll( + std::vector const& requests, + OnUploadEntryEnded onEnded, + [[maybe_unused]] OnUploadEntryProgress onProgress + ) override { return withChannel( [&requests, diff --git a/backend/test/backend/test_batching_remote.hpp b/backend/test/backend/test_batching_remote.hpp index 0ea7cd43..58835712 100644 --- a/backend/test/backend/test_batching_remote.hpp +++ b/backend/test/backend/test_batching_remote.hpp @@ -40,6 +40,8 @@ namespace Test OnEntryEnded onEntryEnded{}; OnUploadEnded onUploadEnded{}; OnUploadEntryEnded onUploadEntryEnded{}; + OnUploadProgress onUploadProgress{}; + OnUploadEntryProgress onUploadEntryProgress{}; }; std::expected, std::string> @@ -71,8 +73,11 @@ namespace Test return {}; } - std::expected - upload(FileTracking::UploadRequest const& request, OnUploadEnded onEnded) override + std::expected upload( + FileTracking::UploadRequest const& request, + OnUploadEnded onEnded, + OnUploadProgress onProgress + ) override { if (failQueueing) return std::unexpected{std::string{"refused"}}; @@ -80,6 +85,7 @@ namespace Test .kind = "upload", .paths = {request.destination.value()}, .onUploadEnded = std::move(onEnded), + .onUploadProgress = std::move(onProgress), }); return {}; } @@ -113,12 +119,20 @@ namespace Test return {}; } - std::expected - uploadAll(std::vector const& requests, OnUploadEntryEnded onEnded) override + std::expected uploadAll( + std::vector const& requests, + OnUploadEntryEnded onEnded, + OnUploadEntryProgress onProgress + ) override { if (failQueueing) return std::unexpected{std::string{"refused"}}; - Queued bulk{.kind = "uploadAll", .paths = {}, .onUploadEntryEnded = std::move(onEnded)}; + Queued bulk{ + .kind = "uploadAll", + .paths = {}, + .onUploadEntryEnded = std::move(onEnded), + .onUploadEntryProgress = std::move(onProgress), + }; for (auto const& request : requests) bulk.paths.push_back(request.destination.value()); queued.push_back(std::move(bulk)); @@ -223,7 +237,8 @@ namespace Test .destination = remote("/srv/" + name), .createMissingDirectories = createMissingDirectories, }, - recordUploadAs(name) + recordUploadAs(name), + {} ) .has_value()); } @@ -312,7 +327,8 @@ namespace Test .destination = remote("/srv/directory"), .createMissingDirectories = true, }, - recordUploadAs("directory") + recordUploadAs("directory"), + {} ) .has_value()); @@ -489,7 +505,8 @@ namespace Test .createMissingDirectories = true, .continuesPartialOf = remote("/srv/c.txt"), }, - recordUploadAs("renamed.txt") + recordUploadAs("renamed.txt"), + {} ) .has_value()); diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 16aaf0ef..e6ef49ef 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -55,6 +55,7 @@ namespace Test FileTracking::UploadRequest upload{}; OnEnded onEnded{}; OnUploadEnded onUploadEnded{}; + OnUploadProgress onUploadProgress{}; bool ended{false}; }; @@ -90,8 +91,11 @@ namespace Test return {}; } - std::expected - upload(FileTracking::UploadRequest const& request, OnUploadEnded onEnded) override + std::expected upload( + FileTracking::UploadRequest const& request, + OnUploadEnded onEnded, + OnUploadProgress onProgress + ) override { queued.push_back({ .kind = "upload", @@ -100,6 +104,7 @@ namespace Test .upload = request, .onEnded = {}, .onUploadEnded = std::move(onEnded), + .onUploadProgress = std::move(onProgress), }); return {}; } From 8404bff5cc646cff57581a43a440801f920483f7 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:40:59 +0200 Subject: [PATCH 006/114] Let owners of queued operations observe their progress - whenProgressing stores the observer until the operation completes, nothing calls it yet --- backend/include/backend/sftp/operation_queue.hpp | 14 ++++++++++++++ backend/source/backend/sftp/operation_queue.cpp | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/backend/include/backend/sftp/operation_queue.hpp b/backend/include/backend/sftp/operation_queue.hpp index 45d9b6c3..2df46b1f 100644 --- a/backend/include/backend/sftp/operation_queue.hpp +++ b/backend/include/backend/sftp/operation_queue.hpp @@ -15,7 +15,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -35,6 +37,7 @@ #include #include #include +#include #include class OperationQueue @@ -47,6 +50,9 @@ class OperationQueue using Error = SharedData::OperationErrorType; using OperationCompleted = SharedData::OperationCompleted; using CompletionReason = SharedData::OperationCompletionReason; + /** @brief What the frontend hears of a running transfer: a single file's, or a bulk's or directory's. */ + using OperationProgress = std::variant; + using ProgressObserver = std::function; public: OperationQueue( @@ -373,6 +379,12 @@ class OperationQueue */ void whenCompleted(Ids::OperationId const& operationId, std::function callback); + /** + * @brief Calls @p observer on the strand with each progress of the operation @p operationId the frontend hears of, + * until it completed. Must be called from the strand, before the operation starts. + */ + void whenProgressing(Ids::OperationId const& operationId, ProgressObserver observer); + /** * @brief Keeps no resume backup of the operation @p operationId, a reconnect does not queue it again. For an owner * that decides anew what goes to the server. Must be called from the strand. @@ -508,6 +520,8 @@ class OperationQueue std::function completionObserver_{}; std::unordered_map, Ids::IdHash> completionCallbacks_{}; + /** @brief See whenProgressing(). */ + std::unordered_map progressObservers_{}; /** @brief See leaveChangedDestinationsToOwner(). */ std::unordered_set changedDestinationsLeftToOwner_{}; // Transfer slots handed out per queue tick. diff --git a/backend/source/backend/sftp/operation_queue.cpp b/backend/source/backend/sftp/operation_queue.cpp index 609ad5c1..1d5537fc 100644 --- a/backend/source/backend/sftp/operation_queue.cpp +++ b/backend/source/backend/sftp/operation_queue.cpp @@ -580,6 +580,7 @@ void OperationQueue::completeOperation(SharedData::OperationCompleted&& operatio // completion (success, failure, or cancel) and no longer needs // a backup for reconnect adoption. self->bulkResumeStash_.erase(operationCompleted.operationId); + self->progressObservers_.erase(operationCompleted.operationId); if (self->completionObserver_) self->completionObserver_(operationCompleted); @@ -603,6 +604,11 @@ void OperationQueue::whenCompleted( completionCallbacks_.insert_or_assign(operationId, std::move(callback)); } +void OperationQueue::whenProgressing(Ids::OperationId const& operationId, ProgressObserver observer) +{ + progressObservers_.insert_or_assign(operationId, std::move(observer)); +} + void OperationQueue::discardResumeBackup(Ids::OperationId const& operationId) { bulkResumeStash_.erase(operationId); From 052ab622697e2f5f604d11b0e3fdcb4bae290b1f Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:40:59 +0200 Subject: [PATCH 007/114] Declared the origin and the listing and removal of orphaned instances as stubs --- .../file_tracking/temp_dir_instance_manager.hpp | 14 ++++++++++++++ .../backend/file_tracking/temp_dir_instancing.hpp | 9 +++++++++ .../file_tracking/temp_dir_instance_manager.cpp | 12 ++++++++++++ .../backend/file_tracking/temp_dir_instancing.cpp | 6 ++++++ 4 files changed, 41 insertions(+) diff --git a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp index 29371fd5..68bb4655 100644 --- a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -151,6 +153,18 @@ namespace FileTracking */ int removeOrphaned(); + /** + * @brief The instance directories below the root that no live process holds, with what they hold and where + * it came from. + */ + std::vector listOrphaned() const; + + /** + * @brief Removes the directory of the orphaned instance @p instanceId with everything in it. + * @return An error when it is no orphaned instance below the root or cannot be removed. + */ + std::expected removeOrphanedInstance(Ids::InstanceId const& instanceId); + /** * @brief Look up an instance by ID. * diff --git a/backend/include/backend/file_tracking/temp_dir_instancing.hpp b/backend/include/backend/file_tracking/temp_dir_instancing.hpp index bb38d696..0dcf88bf 100644 --- a/backend/include/backend/file_tracking/temp_dir_instancing.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instancing.hpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -103,6 +105,13 @@ namespace FileTracking ChangeSink sink = [](ReportedChange const&) {} ); + /** + * @brief Records in the metadata that the instance holds a download of @p remotePath, beside the fields older + * builds read. + * @return An error when the metadata cannot be written. + */ + std::expected recordOrigin(Paths::RemotePath const& remotePath); + /** * @brief Immediately delete the instance directory and invalidate this * object. No death timestamp is written. diff --git a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp index 055b006c..29c84bc6 100644 --- a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp @@ -350,6 +350,18 @@ namespace FileTracking return forceCleanOrphanedInstanceDirs(tempRootDir_, listInstanceIds()); } + std::vector TempDirInstanceManager::listOrphaned() const + { + return {}; + } + + std::expected TempDirInstanceManager::removeOrphanedInstance(Ids::InstanceId const& instanceId) + { + return std::unexpected{ + fmt::format("Removing the orphaned instance '{}' is not implemented", instanceId.value()) + }; + } + TemporaryDirectoryInstance* TempDirInstanceManager::findInstance(std::string const& instanceId) const { diff --git a/backend/source/backend/file_tracking/temp_dir_instancing.cpp b/backend/source/backend/file_tracking/temp_dir_instancing.cpp index fa07a96b..eb66617d 100644 --- a/backend/source/backend/file_tracking/temp_dir_instancing.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instancing.cpp @@ -152,6 +152,12 @@ namespace FileTracking ); } + std::expected TemporaryDirectoryInstance::recordOrigin(Paths::RemotePath const& remotePath) + { + Log::debug("recordOrigin: not recording '{}' for instance '{}' yet", remotePath.value(), impl_->instanceId); + return {}; + } + void TemporaryDirectoryInstance::cleanupNow() { if (!impl_) From bb0b78a8024b3c1abe7332ee70e8e1dbe6ad494d Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:41:06 +0200 Subject: [PATCH 008/114] Registered the conflict, failure, stopped and orphan RPCs on tracker stubs - The tracker's new public methods only log that they are not implemented yet --- .../include/backend/file_tracking/tracker.hpp | 60 +++++++++ .../source/backend/file_tracking/tracker.cpp | 119 ++++++++++++++++++ .../source/backend/session_file_tracking.cpp | 73 +++++++++++ 3 files changed, 252 insertions(+) diff --git a/backend/include/backend/file_tracking/tracker.hpp b/backend/include/backend/file_tracking/tracker.hpp index 095faa3c..2b613b41 100644 --- a/backend/include/backend/file_tracking/tracker.hpp +++ b/backend/include/backend/file_tracking/tracker.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -163,6 +164,65 @@ namespace FileTracking */ void answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer); + /** + * @brief The user's answer to the conflict @p conflictId, see TrackerView::showConflict(). ReplaceOnServer + * checks the path again and uploads it, KeepServers asks again on the next save. Either removes the + * conflict. + */ + void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution); + + /** + * @brief Answers every conflict of the watch @p watchId with @p resolution, see answerConflict(). + */ + void answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution); + + /** + * @brief Uploads the path of the failure @p failureId again, see TrackerView::showFailure(). + */ + void retryFailure(Ids::FailureId const& failureId); + + /** + * @brief Forgets the failure @p failureId, its path waits for its next change. + */ + void dismissFailure(Ids::FailureId const& failureId); + + /** + * @brief Retries every failure of the watch @p watchId, see retryFailure(). + */ + void retryAllFailures(Ids::WatchId const& watchId); + + /** + * @brief Dismisses every failure of the watch @p watchId, see dismissFailure(). + */ + void dismissAllFailures(Ids::WatchId const& watchId); + + /** + * @brief Removes the stopped watch @p watchId from the view, a stopped copy's instance becomes an orphan. + */ + void removeStopped(Ids::WatchId const& watchId); + + /** + * @brief Removes every stopped watch, see removeStopped(). + */ + void removeAllStopped(); + + /** + * @brief Shows the orphaned copies again, see TrackerView::showOrphanedCopies(). Those of stopped watches are + * left out. + */ + void listOrphanedCopies(); + + /** + * @brief Removes the orphaned copy @p instanceId with its directory. + * @return An error when it is no orphaned copy or cannot be removed. + */ + std::expected removeOrphanedCopy(Ids::InstanceId const& instanceId); + + /** + * @brief Removes every orphaned copy, see removeOrphanedCopy(). + */ + void removeAllOrphanedCopies(); + /** * @brief Ends every watch without telling the view, for a frontend that is gone or a session that closes. The * instances of the copies it downloaded, watched or only opened, end too: they count as orphaned then, diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 0ae4b73c..f9cc58e2 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -1910,6 +1910,70 @@ namespace FileTracking askNextServerChanged(); } + void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + { + Log::warn( + "FileTracking: answering conflict {} with {} is not implemented", + conflictId.value(), + Utility::enumToString(resolution) + ); + } + + void answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) + { + Log::warn( + "FileTracking: answering the conflicts of watch {} with {} is not implemented", + watchId.value(), + Utility::enumToString(resolution) + ); + } + + void retryFailure(Ids::FailureId const& failureId) + { + Log::warn("FileTracking: retrying failure {} is not implemented", failureId.value()); + } + + void dismissFailure(Ids::FailureId const& failureId) + { + Log::warn("FileTracking: dismissing failure {} is not implemented", failureId.value()); + } + + void retryAllFailures(Ids::WatchId const& watchId) + { + Log::warn("FileTracking: retrying the failures of watch {} is not implemented", watchId.value()); + } + + void dismissAllFailures(Ids::WatchId const& watchId) + { + Log::warn("FileTracking: dismissing the failures of watch {} is not implemented", watchId.value()); + } + + void removeStopped(Ids::WatchId const& watchId) + { + Log::warn("FileTracking: removing the stopped watch {} is not implemented", watchId.value()); + } + + void removeAllStopped() + { + Log::warn("FileTracking: removing the stopped watches is not implemented"); + } + + void listOrphanedCopies() + { + Log::warn("FileTracking: listing the orphaned copies is not implemented"); + } + + std::expected removeOrphanedCopy(Ids::InstanceId const& instanceId) + { + Log::warn("FileTracking: removing the orphaned copy {} is not implemented", instanceId.value()); + return std::unexpected{std::string{"Removing an orphaned copy is not implemented"}}; + } + + void removeAllOrphanedCopies() + { + Log::warn("FileTracking: removing the orphaned copies is not implemented"); + } + /** * @brief Another dialog replaced the question for @p upload: it and the waiting questions are asked again on * the next save, nothing opens now over the dialog that replaced it. @@ -2284,6 +2348,61 @@ namespace FileTracking impl_->answer(questionId, answer); } + void Tracker::answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + { + impl_->answerConflict(conflictId, resolution); + } + + void Tracker::answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) + { + impl_->answerAllConflicts(watchId, resolution); + } + + void Tracker::retryFailure(Ids::FailureId const& failureId) + { + impl_->retryFailure(failureId); + } + + void Tracker::dismissFailure(Ids::FailureId const& failureId) + { + impl_->dismissFailure(failureId); + } + + void Tracker::retryAllFailures(Ids::WatchId const& watchId) + { + impl_->retryAllFailures(watchId); + } + + void Tracker::dismissAllFailures(Ids::WatchId const& watchId) + { + impl_->dismissAllFailures(watchId); + } + + void Tracker::removeStopped(Ids::WatchId const& watchId) + { + impl_->removeStopped(watchId); + } + + void Tracker::removeAllStopped() + { + impl_->removeAllStopped(); + } + + void Tracker::listOrphanedCopies() + { + impl_->listOrphanedCopies(); + } + + std::expected Tracker::removeOrphanedCopy(Ids::InstanceId const& instanceId) + { + return impl_->removeOrphanedCopy(instanceId); + } + + void Tracker::removeAllOrphanedCopies() + { + impl_->removeAllOrphanedCopies(); + } + void Tracker::stopAll() { impl_->stopAll(); diff --git a/backend/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index 01d7001b..e5eb082b 100644 --- a/backend/source/backend/session_file_tracking.cpp +++ b/backend/source/backend/session_file_tracking.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -896,4 +897,76 @@ void Session::registerRpcFileTracker() } } ); + + // Registers a function that calls @p call on the tracker and answers {success: true}. + const auto onCall = [this, &name, &withTracker](std::string_view function, auto call) + { + on(name(function)) + .perform( + [withTracker, call](RpcHelper::RpcOnce&& reply) + { + if (const auto self = withTracker(reply)) + { + std::invoke(call, *self->tracker_); + reply({{"success", true}}); + } + } + ); + }; + // Like onCall, @p call gets the id the frontend sent, made with @p makeId. + const auto onIdCall = [this, &name, &withTracker](std::string_view function, auto makeId, auto call) + { + on(name(function)) + .perform( + [withTracker, makeId, call](RpcHelper::RpcOnce&& reply, std::string const& id) + { + if (const auto self = withTracker(reply)) + { + std::invoke(call, *self->tracker_, makeId(id)); + reply({{"success", true}}); + } + } + ); + }; + // Like onIdCall, @p call gets the resolution the frontend sent too. + const auto onAnswerCall = [this, &name, &withTracker](std::string_view function, auto makeId, auto call) + { + on(name(function)) + .perform( + [withTracker, + makeId, + call](RpcHelper::RpcOnce&& reply, std::string const& id, SharedData::ConflictResolution resolution) + { + if (const auto self = withTracker(reply)) + { + std::invoke(call, *self->tracker_, makeId(id), resolution); + reply({{"success", true}}); + } + } + ); + }; + + onAnswerCall("answerConflict", &Ids::makeConflictId, &FileTracking::Tracker::answerConflict); + onAnswerCall("answerAllConflicts", &Ids::makeWatchId, &FileTracking::Tracker::answerAllConflicts); + onIdCall("retryFailure", &Ids::makeFailureId, &FileTracking::Tracker::retryFailure); + onIdCall("dismissFailure", &Ids::makeFailureId, &FileTracking::Tracker::dismissFailure); + onIdCall("retryAllFailures", &Ids::makeWatchId, &FileTracking::Tracker::retryAllFailures); + onIdCall("dismissAllFailures", &Ids::makeWatchId, &FileTracking::Tracker::dismissAllFailures); + onIdCall("removeStopped", &Ids::makeWatchId, &FileTracking::Tracker::removeStopped); + onCall("removeAllStopped", &FileTracking::Tracker::removeAllStopped); + onCall("listOrphanedCopies", &FileTracking::Tracker::listOrphanedCopies); + onCall("removeAllOrphanedCopies", &FileTracking::Tracker::removeAllOrphanedCopies); + + on(name("removeOrphanedCopy")) + .perform( + [withTracker](RpcHelper::RpcOnce&& reply, std::string const& instanceId) + { + if (const auto self = withTracker(reply)) + replyWith( + reply, + self->tracker_->removeOrphanedCopy(Ids::makeInstanceId(instanceId)), + nlohmann::json::object() + ); + } + ); } From 2f4690ed94e2a280c370f394c1904a9e01a1641c Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:41:06 +0200 Subject: [PATCH 009/114] Added empty files for the file tracking helpers and their tests --- .../include/backend/file_tracking/bulk_entry_progress.hpp | 1 + backend/include/backend/file_tracking/conflict_list.hpp | 1 + backend/include/backend/file_tracking/failure_list.hpp | 1 + backend/include/backend/file_tracking/failure_reason.hpp | 1 + .../include/backend/file_tracking/watch_progress_meter.hpp | 1 + backend/include/backend/sftp/local_file_problem.hpp | 1 + backend/source/backend/CMakeLists.txt | 4 ++++ .../source/backend/file_tracking/bulk_entry_progress.cpp | 1 + backend/source/backend/file_tracking/failure_reason.cpp | 1 + .../source/backend/file_tracking/watch_progress_meter.cpp | 1 + backend/source/backend/sftp/local_file_problem.cpp | 1 + backend/test/backend/main.cpp | 6 ++++++ backend/test/backend/test_bulk_entry_progress.hpp | 1 + backend/test/backend/test_conflict_list.hpp | 1 + backend/test/backend/test_failure_list.hpp | 1 + backend/test/backend/test_failure_reason.hpp | 1 + backend/test/backend/test_local_file_problem.hpp | 1 + backend/test/backend/test_watch_progress_meter.hpp | 1 + 18 files changed, 26 insertions(+) create mode 100644 backend/include/backend/file_tracking/bulk_entry_progress.hpp create mode 100644 backend/include/backend/file_tracking/conflict_list.hpp create mode 100644 backend/include/backend/file_tracking/failure_list.hpp create mode 100644 backend/include/backend/file_tracking/failure_reason.hpp create mode 100644 backend/include/backend/file_tracking/watch_progress_meter.hpp create mode 100644 backend/include/backend/sftp/local_file_problem.hpp create mode 100644 backend/source/backend/file_tracking/bulk_entry_progress.cpp create mode 100644 backend/source/backend/file_tracking/failure_reason.cpp create mode 100644 backend/source/backend/file_tracking/watch_progress_meter.cpp create mode 100644 backend/source/backend/sftp/local_file_problem.cpp create mode 100644 backend/test/backend/test_bulk_entry_progress.hpp create mode 100644 backend/test/backend/test_conflict_list.hpp create mode 100644 backend/test/backend/test_failure_list.hpp create mode 100644 backend/test/backend/test_failure_reason.hpp create mode 100644 backend/test/backend/test_local_file_problem.hpp create mode 100644 backend/test/backend/test_watch_progress_meter.hpp diff --git a/backend/include/backend/file_tracking/bulk_entry_progress.hpp b/backend/include/backend/file_tracking/bulk_entry_progress.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/include/backend/file_tracking/bulk_entry_progress.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/include/backend/file_tracking/conflict_list.hpp b/backend/include/backend/file_tracking/conflict_list.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/include/backend/file_tracking/conflict_list.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/include/backend/file_tracking/failure_list.hpp b/backend/include/backend/file_tracking/failure_list.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/include/backend/file_tracking/failure_list.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/include/backend/file_tracking/failure_reason.hpp b/backend/include/backend/file_tracking/failure_reason.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/include/backend/file_tracking/failure_reason.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/include/backend/file_tracking/watch_progress_meter.hpp b/backend/include/backend/file_tracking/watch_progress_meter.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/include/backend/file_tracking/watch_progress_meter.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/include/backend/sftp/local_file_problem.hpp b/backend/include/backend/sftp/local_file_problem.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/include/backend/sftp/local_file_problem.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/source/backend/CMakeLists.txt b/backend/source/backend/CMakeLists.txt index 17169348..c931ec1a 100644 --- a/backend/source/backend/CMakeLists.txt +++ b/backend/source/backend/CMakeLists.txt @@ -22,12 +22,14 @@ add_library( process/process_store.cpp process/environment.cpp file_tracking/batching_remote.cpp + file_tracking/bulk_entry_progress.cpp file_tracking/change_filter.cpp file_tracking/change_reporter.cpp file_tracking/directory_watch_service.cpp file_tracking/directory_watcher.cpp file_tracking/disk_place.cpp file_tracking/event_spelling.cpp + file_tracking/failure_reason.cpp file_tracking/file_states.cpp file_tracking/local_item_watch.cpp file_tracking/local_watch_manager.cpp @@ -39,6 +41,7 @@ add_library( file_tracking/temp_dir_instance_manager.cpp file_tracking/frontend_tracker_view.cpp file_tracking/tracker.cpp + file_tracking/watch_progress_meter.cpp sftp/operation_queue.cpp sftp/download_operation.cpp sftp/upload_operation.cpp @@ -51,6 +54,7 @@ add_library( sftp/bulk_resume_registry.cpp sftp/compared_state_check.cpp sftp/delete_operation.cpp + sftp/local_file_problem.cpp sftp/local_delete_operation.cpp sftp/remote_delete_guard.cpp sftp/rename_operation.cpp diff --git a/backend/source/backend/file_tracking/bulk_entry_progress.cpp b/backend/source/backend/file_tracking/bulk_entry_progress.cpp new file mode 100644 index 00000000..7e477237 --- /dev/null +++ b/backend/source/backend/file_tracking/bulk_entry_progress.cpp @@ -0,0 +1 @@ +#include diff --git a/backend/source/backend/file_tracking/failure_reason.cpp b/backend/source/backend/file_tracking/failure_reason.cpp new file mode 100644 index 00000000..5b5d094c --- /dev/null +++ b/backend/source/backend/file_tracking/failure_reason.cpp @@ -0,0 +1 @@ +#include diff --git a/backend/source/backend/file_tracking/watch_progress_meter.cpp b/backend/source/backend/file_tracking/watch_progress_meter.cpp new file mode 100644 index 00000000..d0decae8 --- /dev/null +++ b/backend/source/backend/file_tracking/watch_progress_meter.cpp @@ -0,0 +1 @@ +#include diff --git a/backend/source/backend/sftp/local_file_problem.cpp b/backend/source/backend/sftp/local_file_problem.cpp new file mode 100644 index 00000000..42b9d45f --- /dev/null +++ b/backend/source/backend/sftp/local_file_problem.cpp @@ -0,0 +1 @@ +#include diff --git a/backend/test/backend/main.cpp b/backend/test/backend/main.cpp index a6fc788e..dcfab564 100644 --- a/backend/test/backend/main.cpp +++ b/backend/test/backend/main.cpp @@ -34,6 +34,12 @@ #include "test_tracking_state.hpp" #include "test_tracker.hpp" #include "test_batching_remote.hpp" +#include "test_conflict_list.hpp" +#include "test_failure_list.hpp" +#include "test_watch_progress_meter.hpp" +#include "test_bulk_entry_progress.hpp" +#include "test_failure_reason.hpp" +#include "test_local_file_problem.hpp" #include "test_password_request.hpp" #include "test_sync_session.hpp" #include "test_dropped_entry.hpp" diff --git a/backend/test/backend/test_bulk_entry_progress.hpp b/backend/test/backend/test_bulk_entry_progress.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/test/backend/test_bulk_entry_progress.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/test/backend/test_conflict_list.hpp b/backend/test/backend/test_conflict_list.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/test/backend/test_conflict_list.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/test/backend/test_failure_list.hpp b/backend/test/backend/test_failure_list.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/test/backend/test_failure_list.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/test/backend/test_failure_reason.hpp b/backend/test/backend/test_failure_reason.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/test/backend/test_failure_reason.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/test/backend/test_local_file_problem.hpp b/backend/test/backend/test_local_file_problem.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/test/backend/test_local_file_problem.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/backend/test/backend/test_watch_progress_meter.hpp b/backend/test/backend/test_watch_progress_meter.hpp new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/backend/test/backend/test_watch_progress_meter.hpp @@ -0,0 +1 @@ +#pragma once From 8e1371a28ca0ba858961fa2e50d5d3873b4eb00d Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:53:42 +0200 Subject: [PATCH 010/114] Recorded the remote origin in the instance metadata beside the legacy fields --- .../file_tracking/temp_dir_instancing.hpp | 2 +- .../file_tracking/temp_dir_instancing.cpp | 65 +++++++++-- backend/test/backend/test_file_tracking.hpp | 103 ++++++++++++++++++ 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/backend/include/backend/file_tracking/temp_dir_instancing.hpp b/backend/include/backend/file_tracking/temp_dir_instancing.hpp index 0dcf88bf..1227d1dd 100644 --- a/backend/include/backend/file_tracking/temp_dir_instancing.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instancing.hpp @@ -108,7 +108,7 @@ namespace FileTracking /** * @brief Records in the metadata that the instance holds a download of @p remotePath, beside the fields older * builds read. - * @return An error when the metadata cannot be written. + * @return An error when the metadata cannot be written or cleanupNow() ran. */ std::expected recordOrigin(Paths::RemotePath const& remotePath); diff --git a/backend/source/backend/file_tracking/temp_dir_instancing.cpp b/backend/source/backend/file_tracking/temp_dir_instancing.cpp index eb66617d..70414e6b 100644 --- a/backend/source/backend/file_tracking/temp_dir_instancing.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instancing.cpp @@ -8,14 +8,18 @@ #include #include +#include #include #include #include #include +#include #include #include +#include #include +#include namespace FileTracking { @@ -34,6 +38,29 @@ namespace FileTracking throw std::runtime_error{fmt::format("Failed to create instance directory: {}", created.error())}; return dir; } + + /** + * @brief Reads the metadata of @p instanceDirectory, lets @p change update it and writes it back with every + * other field kept. Metadata that is missing or no object starts over with the instance id. + */ + template + std::expected + updateMetadata(std::filesystem::path const& instanceDirectory, std::string const& instanceId, ChangeT&& change) + { + return readMetadata(instanceDirectory) + .and_then( + [&](std::optional meta) + { + auto updated = std::move(meta).value_or(nlohmann::json::object()); + if (!updated.is_object()) + updated = nlohmann::json::object(); + if (!updated.contains("instanceId")) + updated["instanceId"] = instanceId; + change(updated); + return FileTracking::writeMetadata(instanceDirectory, updated); + } + ); + } } // namespace // ------------------------------------------------------------------------- @@ -154,8 +181,24 @@ namespace FileTracking std::expected TemporaryDirectoryInstance::recordOrigin(Paths::RemotePath const& remotePath) { - Log::debug("recordOrigin: not recording '{}' for instance '{}' yet", remotePath.value(), impl_->instanceId); - return {}; + if (!isValid()) + return std::unexpected{ + fmt::format("Cannot record the origin of instance '{}': it was cleaned up", impl_->instanceId) + }; + return updateMetadata( + impl_->instanceDir, + impl_->instanceId, + [&remotePath](nlohmann::json& meta) + { + meta["remotePath"] = remotePath.value(); + } + ) + .transform_error( + [this](std::string const& error) + { + return fmt::format("Cannot record the origin of instance '{}': {}", impl_->instanceId, error); + } + ); } void TemporaryDirectoryInstance::cleanupNow() @@ -198,16 +241,14 @@ namespace FileTracking void TemporaryDirectoryInstance::writeDeadTimestamp() { - const auto written = - readMetadata(impl_->instanceDir) - .and_then( - [this](std::optional meta) - { - auto updated = std::move(meta).value_or(nlohmann::json{{"instanceId", impl_->instanceId}}); - updated["deadAt"] = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); - return FileTracking::writeMetadata(impl_->instanceDir, updated); - } - ); + const auto written = updateMetadata( + impl_->instanceDir, + impl_->instanceId, + [](nlohmann::json& meta) + { + meta["deadAt"] = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + } + ); if (!written) Log::error("writeDeadTimestamp: failed for instance '{}': {}", impl_->instanceId, written.error()); } diff --git a/backend/test/backend/test_file_tracking.hpp b/backend/test/backend/test_file_tracking.hpp index 8ed54a7f..ec0919a8 100644 --- a/backend/test/backend/test_file_tracking.hpp +++ b/backend/test/backend/test_file_tracking.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include @@ -15,12 +17,18 @@ #include +#include #include +#include #include #include #include #include +#include #include +#include +#include +#include using namespace FileTracking; using namespace std::chrono_literals; @@ -68,6 +76,40 @@ namespace Test return std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; } + /** + * @brief @p path as a remote path, it must be a valid one. + */ + inline Paths::RemotePath remotePathOf(std::string_view path) + { + return Paths::RemotePath::make(path).value(); + } + + /** + * @brief Writes @p size bytes into @p path. + */ + inline void writeFileOfSize(fs::path const& path, std::size_t size) + { + std::ofstream{path, std::ios::binary} << std::string(size, 'x'); + } + + /** + * @brief The copy of the instance @p instanceId in @p copies, nothing when it is not listed. + */ + inline std::optional + listedCopyOf(std::vector const& copies, std::string const& instanceId) + { + const auto found = std::ranges::find_if( + copies, + [&instanceId](SharedData::OrphanedCopy const& copy) + { + return copy.instanceId.value() == instanceId; + } + ); + if (found == copies.end()) + return std::nullopt; + return *found; + } + /** * @brief Writes a metadata file with the given @p deadAt (or null if absent). */ @@ -562,6 +604,67 @@ namespace Test EXPECT_TRUE(moved.isValid()); } + // ---- recordOrigin behaviour ---- + + TEST_F(TemporaryDirectoryInstanceTest, RecordedOriginKeepsTheLegacyFields) + { + auto inst = makeInstance(); + const auto metaPath = bookkeepingPaths(inst->instanceDir()).metadata; + const auto before = readJson(metaPath); + + const auto recorded = inst->recordOrigin(remotePathOf("/srv/data/report.txt")); + + ASSERT_TRUE(recorded.has_value()) << recorded.error(); + const auto after = readJson(metaPath); + EXPECT_EQ(after["instanceId"], inst->instanceId()); + EXPECT_EQ(after["createdAt"], before["createdAt"]); + ASSERT_TRUE(after.contains("deadAt")); + EXPECT_TRUE(after["deadAt"].is_null()); + EXPECT_EQ(after["remotePath"], "/srv/data/report.txt"); + const auto read = readMetadata(inst->instanceDir()); + ASSERT_TRUE(read.has_value()) << read.error(); + ASSERT_TRUE(read->has_value()); + EXPECT_EQ(**read, after); + } + + TEST_F(TemporaryDirectoryInstanceTest, RecordedOriginSurvivesTheDeathTimestamp) + { + fs::path metaPath; + nlohmann::json before; + { + auto inst = makeInstance(); + metaPath = bookkeepingPaths(inst->instanceDir()).metadata; + ASSERT_TRUE(inst->recordOrigin(remotePathOf("/srv/data/report.txt")).has_value()); + before = readJson(metaPath); + } + const auto after = readJson(metaPath); + EXPECT_EQ(after["instanceId"], before["instanceId"]); + EXPECT_EQ(after["createdAt"], before["createdAt"]); + EXPECT_TRUE(after["deadAt"].is_number_integer()); + EXPECT_EQ(after["remotePath"], "/srv/data/report.txt"); + } + + TEST_F(TemporaryDirectoryInstanceTest, RecordingTheOriginAgainReplacesIt) + { + auto inst = makeInstance(); + ASSERT_TRUE(inst->recordOrigin(remotePathOf("/srv/first.txt")).has_value()); + ASSERT_TRUE(inst->recordOrigin(remotePathOf("/srv/second.txt")).has_value()); + EXPECT_EQ(readJson(bookkeepingPaths(inst->instanceDir()).metadata)["remotePath"], "/srv/second.txt"); + } + + TEST_F(TemporaryDirectoryInstanceTest, RecordingTheOriginAfterCleanupNowFails) + { + auto inst = makeInstance(); + const auto dir = inst->instanceDir(); + inst->cleanupNow(); + + const auto recorded = inst->recordOrigin(remotePathOf("/srv/data/report.txt")); + + ASSERT_FALSE(recorded.has_value()); + EXPECT_NE(recorded.error().find(inst->instanceId()), std::string::npos) << recorded.error(); + EXPECT_FALSE(fs::exists(fs::symlink_status(dir))); + } + // ========================================================================= // TempDirInstanceManagerTest // ========================================================================= From a937481ade5fb637842cf4ccdc2173906126ad87 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:57:22 +0200 Subject: [PATCH 011/114] Listed orphaned instances one by one with their copy, origin, size and times --- .../temp_dir_instance_manager.hpp | 3 +- .../temp_dir_instance_manager.cpp | 200 +++++++++++++++- backend/test/backend/test_file_tracking.hpp | 216 +++++++++++++++++- 3 files changed, 404 insertions(+), 15 deletions(-) diff --git a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp index 68bb4655..a8a18f1e 100644 --- a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp @@ -155,7 +155,8 @@ namespace FileTracking /** * @brief The instance directories below the root that no live process holds, with what they hold and where - * it came from. + * it came from, sorted by instance id. The orphan test is the one of countOrphaned(). An instance that + * holds nothing has an empty name and no copy path. */ std::vector listOrphaned() const; diff --git a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp index 29c84bc6..82c274ba 100644 --- a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp @@ -13,10 +13,13 @@ #include #include +#include #include #include +#include #include #include +#include #include namespace FileTracking @@ -41,18 +44,33 @@ namespace FileTracking } /** - * @brief Whether @p dirPath is the directory of an instance that no live process holds: it is not one of - * @p skipIds and has a lock file (in the bookkeeping directory or the legacy one) nobody holds. + * @brief Why @p dirPath is not the directory of an instance that no live process holds, nothing when it is + * one: it is not one of @p skipIds and has a lock file (in the bookkeeping directory or the legacy + * one) nobody holds. * * @param dirPath Path to the candidate subdirectory. * @param skipIds IDs of live instances that must not be touched. */ - bool isOrphanedDir(std::filesystem::path const& dirPath, std::vector const& skipIds) + std::optional + notOrphanedBecause(std::filesystem::path const& dirPath, std::vector const& skipIds) { if (std::ranges::contains(skipIds, Paths::nativeToUtf8(dirPath.filename()))) - return false; + return "this run still uses it"; const auto lock = existingLockFile(dirPath); - return lock && !InstanceLock::isLockedByAnother(*lock); + if (!lock) + return "it is no instance directory"; + if (InstanceLock::isLockedByAnother(*lock)) + return "a running instance holds it"; + return std::nullopt; + } + + /** + * @brief Whether @p dirPath is the directory of an instance that no live process holds, see + * notOrphanedBecause. + */ + bool isOrphanedDir(std::filesystem::path const& dirPath, std::vector const& skipIds) + { + return !notOrphanedBecause(dirPath, skipIds); } /** @@ -171,6 +189,162 @@ namespace FileTracking Log::warn("Failed to remove dead instance '{}': {}", dirName, Utility::errorMessage(rmErr)); } } + + /** + * @brief The names directly in @p instanceDirectory that are its bookkeeping, not its content: the + * bookkeeping directory, and the lock and metadata files of a legacy instance. + */ + std::vector bookkeepingNamesOf(std::filesystem::path const& instanceDirectory) + { + std::vector names{std::filesystem::path{bookkeepingDirectoryName}}; + const auto legacy = legacyBookkeepingPaths(instanceDirectory); + if (existingLockFile(instanceDirectory) == legacy.lock) + { + names.push_back(legacy.lock.filename()); + names.push_back(legacy.metadata.filename()); + } + return names; + } + + /** + * @brief The entries directly in @p instanceDirectory that are content, sorted by name. + */ + std::vector contentEntriesOf( + std::filesystem::path const& instanceDirectory, + std::vector const& bookkeepingNames + ) + { + std::vector entries; + std::error_code walkError; + for ( + std::filesystem::directory_iterator iter{instanceDirectory, walkError}, end; !walkError && iter != end; + iter.increment(walkError) + ) + { + if (!std::ranges::contains(bookkeepingNames, iter->path().filename())) + entries.push_back(iter->path()); + } + std::ranges::sort(entries); + return entries; + } + + /** + * @brief The bytes of the regular files below @p instanceDirectory, a canonical path, its bookkeeping left + * out. + */ + std::uint64_t contentSizeOf( + std::filesystem::path const& instanceDirectory, + std::vector const& bookkeepingNames + ) + { + std::uint64_t size = 0u; + for (auto const& state : InstanceManagerDetail::fileStatesOf(instanceDirectory)) + { + if (state.path.parent_path() != instanceDirectory || + !std::ranges::contains(bookkeepingNames, state.path.filename())) + size += state.size; + } + return size; + } + + /** + * @brief The metadata of @p instanceDirectory, nothing when it has none or it cannot be read. + */ + std::optional metadataOf(std::filesystem::path const& instanceDirectory) + { + auto meta = readMetadata(instanceDirectory); + if (!meta) + { + Log::warn( + "listOrphaned: cannot read the metadata of '{}': {}", + Paths::nativeToUtf8(instanceDirectory.filename()), + meta.error() + ); + return std::nullopt; + } + if (*meta && !(*meta)->is_object()) + return std::nullopt; + return *std::move(meta); + } + + /** + * @brief The seconds in the field @p key of @p meta, nothing when it holds none. + */ + std::optional secondsOf(std::optional const& meta, std::string_view key) + { + if (!meta) + return std::nullopt; + const auto field = meta->find(key); + if (field == meta->end() || !field->is_number_integer() || field->get() < 0) + return std::nullopt; + return static_cast(field->get()); + } + + /** + * @brief Where the copy in an instance came from, see TemporaryDirectoryInstance::recordOrigin. Nothing when + * @p meta does not hold it. + */ + std::optional remotePathOf(std::optional const& meta) + { + if (!meta) + return std::nullopt; + const auto field = meta->find("remotePath"); + if (field == meta->end() || !field->is_string()) + return std::nullopt; + auto remotePath = Paths::RemotePath::make(field->get()); + if (!remotePath) + { + Log::warn("listOrphaned: ignoring the recorded origin: {}", remotePath.error().toString()); + return std::nullopt; + } + return *std::move(remotePath); + } + + /** + * @brief @p path as a local path, nothing when there is none or it is no valid one. + */ + std::optional localPathOf(std::optional const& path) + { + if (!path) + return std::nullopt; + auto localPath = Paths::LocalPath::make(*path); + if (!localPath) + { + Log::warn("listOrphaned: leaving out a path: {}", localPath.error().toString()); + return std::nullopt; + } + return *std::move(localPath); + } + + /** + * @brief What the orphaned instance directory @p directory holds and where it came from, nothing when its + * path is no valid local path. + */ + std::optional orphanedCopyOf(std::filesystem::path const& directory) + { + // Canonical like the paths of the copies the tracker shows. + std::error_code canonicalError; + const auto canonicalDirectory = std::filesystem::weakly_canonical(directory, canonicalError); + const auto instanceDirectory = canonicalError ? directory : canonicalDirectory; + auto localDirectory = localPathOf(instanceDirectory); + if (!localDirectory) + return std::nullopt; + const auto bookkeepingNames = bookkeepingNamesOf(instanceDirectory); + const auto content = contentEntriesOf(instanceDirectory, bookkeepingNames); + const auto firstEntry = + content.empty() ? std::optional{} : std::optional{content.front()}; + const auto meta = metadataOf(instanceDirectory); + return SharedData::OrphanedCopy{ + .instanceId = Ids::makeInstanceId(Paths::nativeToUtf8(instanceDirectory.filename())), + .name = firstEntry ? Paths::nativeToUtf8(firstEntry->filename()) : std::string{}, + .directory = *std::move(localDirectory), + .copyPath = localPathOf(firstEntry), + .remotePath = remotePathOf(meta), + .sizeBytes = contentSizeOf(instanceDirectory, bookkeepingNames), + .downloadedAt = secondsOf(meta, "createdAt"), + .endedAt = secondsOf(meta, "deadAt"), + }; + } } // namespace namespace InstanceManagerDetail @@ -352,7 +526,21 @@ namespace FileTracking std::vector TempDirInstanceManager::listOrphaned() const { - return {}; + std::vector copies; + for (auto const& directory : orphanedInstanceDirs(tempRootDir_, listInstanceIds())) + { + if (auto copy = orphanedCopyOf(directory)) + copies.push_back(*std::move(copy)); + } + std::ranges::sort( + copies, + {}, + [](SharedData::OrphanedCopy const& copy) + { + return copy.instanceId.value(); + } + ); + return copies; } std::expected TempDirInstanceManager::removeOrphanedInstance(Ids::InstanceId const& instanceId) diff --git a/backend/test/backend/test_file_tracking.hpp b/backend/test/backend/test_file_tracking.hpp index ec0919a8..1bf97506 100644 --- a/backend/test/backend/test_file_tracking.hpp +++ b/backend/test/backend/test_file_tracking.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -616,11 +617,11 @@ namespace Test ASSERT_TRUE(recorded.has_value()) << recorded.error(); const auto after = readJson(metaPath); - EXPECT_EQ(after["instanceId"], inst->instanceId()); - EXPECT_EQ(after["createdAt"], before["createdAt"]); + EXPECT_EQ(after.at("instanceId"), inst->instanceId()); + EXPECT_EQ(after.at("createdAt"), before.at("createdAt")); ASSERT_TRUE(after.contains("deadAt")); - EXPECT_TRUE(after["deadAt"].is_null()); - EXPECT_EQ(after["remotePath"], "/srv/data/report.txt"); + EXPECT_TRUE(after.at("deadAt").is_null()); + EXPECT_EQ(after.at("remotePath"), "/srv/data/report.txt"); const auto read = readMetadata(inst->instanceDir()); ASSERT_TRUE(read.has_value()) << read.error(); ASSERT_TRUE(read->has_value()); @@ -638,10 +639,10 @@ namespace Test before = readJson(metaPath); } const auto after = readJson(metaPath); - EXPECT_EQ(after["instanceId"], before["instanceId"]); - EXPECT_EQ(after["createdAt"], before["createdAt"]); - EXPECT_TRUE(after["deadAt"].is_number_integer()); - EXPECT_EQ(after["remotePath"], "/srv/data/report.txt"); + EXPECT_EQ(after.at("instanceId"), before.at("instanceId")); + EXPECT_EQ(after.at("createdAt"), before.at("createdAt")); + EXPECT_TRUE(after.at("deadAt").is_number_integer()); + EXPECT_EQ(after.at("remotePath"), "/srv/data/report.txt"); } TEST_F(TemporaryDirectoryInstanceTest, RecordingTheOriginAgainReplacesIt) @@ -1166,4 +1167,203 @@ namespace Test EXPECT_NO_THROW(mgr->manualCleanup()); } + // ---- listOrphaned behaviour ---- + + TEST_F(TempDirInstanceManagerTest, ListingOrphansDescribesAnEndedCopy) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + const auto id = instance->instanceId(); + writeFileOfSize(directory / "notes.txt", 5u); + ASSERT_TRUE(instance->recordOrigin(remotePathOf("/srv/notes.txt")).has_value()); + mgr->destroyInstance(id); + const auto meta = readJson(bookkeepingPaths(directory).metadata); + + const auto copies = mgr->listOrphaned(); + + ASSERT_EQ(copies.size(), 1u); + auto const& copy = copies.front(); + EXPECT_EQ(copy.instanceId.value(), id); + EXPECT_EQ(copy.name, "notes.txt"); + EXPECT_EQ(copy.directory.native(), fs::weakly_canonical(directory)); + ASSERT_TRUE(copy.copyPath.has_value()); + EXPECT_EQ(copy.copyPath->native(), fs::weakly_canonical(directory) / "notes.txt"); + ASSERT_TRUE(copy.remotePath.has_value()); + EXPECT_EQ(copy.remotePath->value(), "/srv/notes.txt"); + EXPECT_EQ(copy.sizeBytes, 5u); + ASSERT_TRUE(copy.downloadedAt.has_value()); + EXPECT_EQ(*copy.downloadedAt, meta.at("createdAt").get()); + ASSERT_TRUE(copy.endedAt.has_value()); + EXPECT_EQ(*copy.endedAt, meta.at("deadAt").get()); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansLeavesOutLiveLockedAndForeignDirectories) + { + auto mgr = makeManager(); + auto* live = mgr->createInstance(); + ASSERT_NE(live, nullptr); + writeFileOfSize(live->instanceDir() / "live.txt", 3u); + auto otherRun = makeManager(); + auto* otherRunInstance = otherRun->createInstance(); + ASSERT_NE(otherRunInstance, nullptr); + makeDeadBookkeepingInstanceDir(root_, "held-elsewhere", std::chrono::system_clock::now() - 1h); + InstanceLock heldLock{bookkeepingPaths(root_ / "held-elsewhere").lock}; + fs::create_directories(root_ / "no-lock-dir"); + makeDeadBookkeepingInstanceDir(root_, "orphan-b", std::chrono::system_clock::now() - 1h); + makeDeadBookkeepingInstanceDir(root_, "orphan-a", std::chrono::system_clock::now() - 2h); + + const auto copies = mgr->listOrphaned(); + + ASSERT_EQ(copies.size(), 2u); + EXPECT_EQ(copies[0].instanceId.value(), "orphan-a"); + EXPECT_EQ(copies[1].instanceId.value(), "orphan-b"); + EXPECT_EQ(mgr->countOrphaned(), copies.size()); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansSumsTheSizesBelowADirectory) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "tree-copy", std::chrono::system_clock::now() - 1h); + const auto tree = root_ / "tree-copy" / "project"; + fs::create_directories(tree / "below"); + writeFileOfSize(tree / "first.bin", 100u); + writeFileOfSize(tree / "below" / "second.bin", 23u); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "tree-copy"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_EQ(copy->name, "project"); + ASSERT_TRUE(copy->copyPath.has_value()); + EXPECT_EQ(copy->copyPath->native(), fs::weakly_canonical(tree)); + EXPECT_EQ(copy->sizeBytes, 123u); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansNamesTheFirstOfSeveralEntries) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "several", std::chrono::system_clock::now() - 1h); + writeFileOfSize(root_ / "several" / "zeta.txt", 1u); + writeFileOfSize(root_ / "several" / "alpha.txt", 2u); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "several"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_EQ(copy->name, "alpha.txt"); + EXPECT_EQ(copy->sizeBytes, 3u); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansIncludesAnInstanceWithoutRecordedOrigin) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "older-build", std::chrono::system_clock::now() - 1h); + writeFileOfSize(root_ / "older-build" / "old.txt", 4u); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "older-build"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_EQ(copy->name, "old.txt"); + EXPECT_FALSE(copy->remotePath.has_value()); + EXPECT_EQ(copy->sizeBytes, 4u); + EXPECT_TRUE(copy->downloadedAt.has_value()); + EXPECT_TRUE(copy->endedAt.has_value()); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansIncludesALegacyLayoutInstance) + { + auto mgr = makeManager(); + const auto deadAt = std::chrono::system_clock::now() - 1h; + makeDeadInstanceDir(root_, "legacy-layout", deadAt); + writeFileOfSize(root_ / "legacy-layout" / "report.txt", 7u); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "legacy-layout"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_EQ(copy->name, "report.txt"); + EXPECT_EQ(copy->sizeBytes, 7u); + EXPECT_FALSE(copy->remotePath.has_value()); + ASSERT_TRUE(copy->endedAt.has_value()); + EXPECT_EQ(*copy->endedAt, static_cast(std::chrono::system_clock::to_time_t(deadAt))); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansCountsContentNamedLikeLegacyBookkeeping) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "named-alike", std::chrono::system_clock::now() - 1h); + writeFileOfSize(root_ / "named-alike" / "metadata.json", 6u); + writeFileOfSize(root_ / "named-alike" / "named-alike.lock", 2u); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "named-alike"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_EQ(copy->name, "metadata.json"); + EXPECT_EQ(copy->sizeBytes, 8u); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansIncludesAnEmptyInstance) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "empty-instance"); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "empty-instance"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_TRUE(copy->name.empty()); + EXPECT_EQ(copy->directory.native(), fs::weakly_canonical(root_ / "empty-instance")); + EXPECT_FALSE(copy->copyPath.has_value()); + EXPECT_EQ(copy->sizeBytes, 0u); + EXPECT_TRUE(copy->downloadedAt.has_value()); + EXPECT_FALSE(copy->endedAt.has_value()); + EXPECT_TRUE(readJson(bookkeepingPaths(root_ / "empty-instance").metadata)["deadAt"].is_null()); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansIgnoresAnInvalidRecordedOrigin) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "bad-origin", std::chrono::system_clock::now() - 1h); + const auto metaPath = bookkeepingPaths(root_ / "bad-origin").metadata; + auto meta = readJson(metaPath); + meta["remotePath"] = "relative/path.txt"; + std::ofstream{metaPath} << meta.dump(4); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "bad-origin"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_FALSE(copy->remotePath.has_value()); + EXPECT_TRUE(copy->endedAt.has_value()); + } + + TEST_F(TempDirInstanceManagerTest, ListingOrphansIncludesAnInstanceWithUnreadableMetadata) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "broken-metadata"); + std::ofstream{bookkeepingPaths(root_ / "broken-metadata").metadata} << "no json"; + writeFileOfSize(root_ / "broken-metadata" / "kept.txt", 9u); + + const auto copy = listedCopyOf(mgr->listOrphaned(), "broken-metadata"); + + ASSERT_TRUE(copy.has_value()); + EXPECT_EQ(copy->name, "kept.txt"); + EXPECT_EQ(copy->sizeBytes, 9u); + EXPECT_FALSE(copy->remotePath.has_value()); + EXPECT_FALSE(copy->downloadedAt.has_value()); + EXPECT_FALSE(copy->endedAt.has_value()); + } + + TEST_F(TempDirInstanceManagerTest, RecordedOriginKeepsTheInstanceReadableForTheCleanup) + { + auto mgr = makeManager(0h); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + ASSERT_TRUE(instance->recordOrigin(remotePathOf("/srv/notes.txt")).has_value()); + mgr->destroyInstance(instance->instanceId()); + ASSERT_EQ(mgr->countOrphaned(), 1u); + + mgr->manualCleanup(); + + EXPECT_FALSE(fs::exists(fs::symlink_status(directory))); + } + } // namespace Test From 6149ef1427336ea69ad4d6e8228fef91976cd009 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:57:49 +0200 Subject: [PATCH 012/114] Removed one orphaned instance at a time, refusing live, locked and non-plain ids --- .../temp_dir_instance_manager.hpp | 6 +- .../temp_dir_instance_manager.cpp | 68 +++++++- backend/test/backend/test_file_tracking.hpp | 160 ++++++++++++++++++ 3 files changed, 229 insertions(+), 5 deletions(-) diff --git a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp index a8a18f1e..6dfa153f 100644 --- a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp @@ -67,7 +67,8 @@ namespace FileTracking * @brief Owns and tracks the TemporaryDirectoryInstance objects of every session. * * The manager also owns the dead-instance cleanup policy: the retention scan runs once at start (see - * scheduleStartupCleanup), the user's Clean Up removes every orphaned directory at once (removeOrphaned). + * scheduleStartupCleanup), the user removes orphaned directories all at once (removeOrphaned) or one at a time + * (removeOrphanedInstance). * Lives on its strand: every member must be called from it. */ class TempDirInstanceManager @@ -162,7 +163,8 @@ namespace FileTracking /** * @brief Removes the directory of the orphaned instance @p instanceId with everything in it. - * @return An error when it is no orphaned instance below the root or cannot be removed. + * @return An error when @p instanceId is no plain directory name, names no directory below the root, the + * directory is no orphan (see listOrphaned()) or it cannot be removed. */ std::expected removeOrphanedInstance(Ids::InstanceId const& instanceId); diff --git a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp index 82c274ba..7067e2ce 100644 --- a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp @@ -190,6 +190,39 @@ namespace FileTracking } } + /** + * @brief @p name as a directory directly below a root: not empty, no separator, no root, not "." or "..". + */ + std::expected plainChildName(std::string const& name) + { + if (name.empty()) + return std::unexpected{std::string{"the instance id is empty"}}; + const auto refused = std::unexpected{std::string{"the instance id is no plain directory name"}}; + if (name == "." || name == ".." || name.find_first_of("/\\") != std::string::npos || + name.find('\0') != std::string::npos) + return refused; + const auto path = Paths::nativeFromUtf8(name); + if (path.has_root_path() || path != path.filename()) + return refused; + return path; + } + + /** + * @brief @p directory when it is a directory, not a link to one. + */ + std::expected existingDirectory(std::filesystem::path const& directory) + { + std::error_code kindError; + const auto kind = Utility::noFollowKindOf(directory, kindError); + if (kindError) + return std::unexpected{Utility::errorMessage(kindError)}; + if (kind == Utility::NoFollowKind::Missing) + return std::unexpected{std::string{"it does not exist"}}; + if (kind != Utility::NoFollowKind::Directory) + return std::unexpected{std::string{"it is no directory"}}; + return directory; + } + /** * @brief The names directly in @p instanceDirectory that are its bookkeeping, not its content: the * bookkeeping directory, and the lock and metadata files of a legacy instance. @@ -545,9 +578,38 @@ namespace FileTracking std::expected TempDirInstanceManager::removeOrphanedInstance(Ids::InstanceId const& instanceId) { - return std::unexpected{ - fmt::format("Removing the orphaned instance '{}' is not implemented", instanceId.value()) - }; + return plainChildName(instanceId.value()) + .and_then( + [this](std::filesystem::path const& name) + { + return existingDirectory(tempRootDir_ / name); + } + ) + .and_then( + [this](std::filesystem::path const& directory) -> std::expected + { + if (const auto reason = notOrphanedBecause(directory, listInstanceIds())) + return std::unexpected{std::string{*reason}}; + return directory; + } + ) + .and_then( + [&instanceId](std::filesystem::path const& directory) -> std::expected + { + Log::info("Removing the orphaned instance '{}'", instanceId.value()); + std::error_code removeError; + Utility::removeAllNoFollow(directory, removeError); + if (removeError) + return std::unexpected{Utility::errorMessage(removeError)}; + return {}; + } + ) + .transform_error( + [&instanceId](std::string const& reason) + { + return fmt::format("Cannot remove the orphaned instance '{}': {}", instanceId.value(), reason); + } + ); } TemporaryDirectoryInstance* diff --git a/backend/test/backend/test_file_tracking.hpp b/backend/test/backend/test_file_tracking.hpp index 1bf97506..44ed11a0 100644 --- a/backend/test/backend/test_file_tracking.hpp +++ b/backend/test/backend/test_file_tracking.hpp @@ -1366,4 +1366,164 @@ namespace Test EXPECT_FALSE(fs::exists(fs::symlink_status(directory))); } + // ---- removeOrphanedInstance behaviour ---- + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanDeletesOnlyIt) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + const auto id = instance->instanceId(); + fs::create_directories(directory / "tree"); + writeFileOfSize(directory / "tree" / "file.txt", 10u); + mgr->destroyInstance(id); + makeDeadBookkeepingInstanceDir(root_, "other-orphan", std::chrono::system_clock::now() - 1h); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId(id)); + + ASSERT_TRUE(removed.has_value()) << removed.error(); + EXPECT_FALSE(fs::exists(fs::symlink_status(directory))); + EXPECT_TRUE(fs::exists(root_ / "other-orphan")); + const auto copies = mgr->listOrphaned(); + ASSERT_EQ(copies.size(), 1u); + EXPECT_EQ(copies.front().instanceId.value(), "other-orphan"); + } + + TEST_F(TempDirInstanceManagerTest, RemovingALegacyLayoutOrphanDeletesIt) + { + auto mgr = makeManager(); + makeDeadInstanceDir(root_, "legacy-orphan", std::chrono::system_clock::now() - 1h); + writeFileOfSize(root_ / "legacy-orphan" / "doc.txt", 3u); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId("legacy-orphan")); + + ASSERT_TRUE(removed.has_value()) << removed.error(); + EXPECT_FALSE(fs::exists(fs::symlink_status(root_ / "legacy-orphan"))); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesALiveInstance) + { + auto mgr = makeManager(); + auto* live = mgr->createInstance(); + ASSERT_NE(live, nullptr); + writeFileOfSize(live->instanceDir() / "live.txt", 3u); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId(live->instanceId())); + + ASSERT_FALSE(removed.has_value()); + EXPECT_NE(removed.error().find(live->instanceId()), std::string::npos) << removed.error(); + EXPECT_TRUE(fs::exists(live->instanceDir() / "live.txt")); + EXPECT_EQ(mgr->findInstance(live->instanceId()), live); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesAnInstanceOfAnotherRun) + { + auto mgr = makeManager(); + auto otherRun = makeManager(); + auto* foreign = otherRun->createInstance(); + ASSERT_NE(foreign, nullptr); + writeFileOfSize(foreign->instanceDir() / "foreign.txt", 3u); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId(foreign->instanceId())); + + ASSERT_FALSE(removed.has_value()); + EXPECT_TRUE(fs::exists(foreign->instanceDir() / "foreign.txt")); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesALockedInstance) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "held-elsewhere", std::chrono::system_clock::now() - 1h); + writeFileOfSize(root_ / "held-elsewhere" / "content.txt", 3u); + InstanceLock heldLock{bookkeepingPaths(root_ / "held-elsewhere").lock}; + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId("held-elsewhere")); + + ASSERT_FALSE(removed.has_value()); + EXPECT_NE(removed.error().find("held-elsewhere"), std::string::npos) << removed.error(); + EXPECT_TRUE(fs::exists(root_ / "held-elsewhere" / "content.txt")); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesIdsThatAreNoPlainName) + { + const auto instancesRoot = root_ / "instances"; + fs::create_directories(instancesRoot); + TempDirInstanceManager mgr{boost::asio::make_strand(ioc_.get_executor()), instancesRoot, 24h}; + makeDeadBookkeepingInstanceDir(root_, "outside", std::chrono::system_clock::now() - 1h); + makeDeadBookkeepingInstanceDir(instancesRoot / "nested", "inner", std::chrono::system_clock::now() - 1h); + const std::vector ids{ + "../outside", + "nested/inner", + "nested\\inner", + ".", + "..", + Paths::nativeToUtf8(root_ / "outside"), + }; + + for (auto const& id : ids) + { + const auto removed = mgr.removeOrphanedInstance(Ids::makeInstanceId(id)); + ASSERT_FALSE(removed.has_value()) << id; + EXPECT_NE(removed.error().find("plain"), std::string::npos) << removed.error(); + } + + EXPECT_TRUE(fs::exists(bookkeepingPaths(root_ / "outside").lock)); + EXPECT_TRUE(fs::exists(bookkeepingPaths(instancesRoot / "nested" / "inner").lock)); + EXPECT_TRUE(fs::exists(instancesRoot)); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesAnEmptyId) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "orphan", std::chrono::system_clock::now() - 1h); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId("")); + + ASSERT_FALSE(removed.has_value()); + EXPECT_NE(removed.error().find("empty"), std::string::npos) << removed.error(); + EXPECT_TRUE(fs::exists(root_ / "orphan")); + EXPECT_TRUE(fs::exists(root_)); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesAMissingInstance) + { + auto mgr = makeManager(); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId("does-not-exist")); + + ASSERT_FALSE(removed.has_value()); + EXPECT_NE(removed.error().find("does-not-exist"), std::string::npos) << removed.error(); + EXPECT_NE(removed.error().find("does not exist"), std::string::npos) << removed.error(); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesADirectoryWithoutLock) + { + auto mgr = makeManager(); + fs::create_directories(root_ / "no-lock-dir"); + writeFileOfSize(root_ / "no-lock-dir" / "content.txt", 3u); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId("no-lock-dir")); + + ASSERT_FALSE(removed.has_value()); + EXPECT_TRUE(fs::exists(root_ / "no-lock-dir" / "content.txt")); + } + + TEST_F(TempDirInstanceManagerTest, RemovingAnOrphanRefusesALinkToAnOrphan) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "real-orphan", std::chrono::system_clock::now() - 1h); + writeFileOfSize(root_ / "real-orphan" / "content.txt", 3u); + std::error_code linkError; + fs::create_directory_symlink(root_ / "real-orphan", root_ / "linked-orphan", linkError); + if (linkError) + GTEST_SKIP() << "Cannot create symlinks here: " << linkError.message(); + + const auto removed = mgr->removeOrphanedInstance(Ids::makeInstanceId("linked-orphan")); + + ASSERT_FALSE(removed.has_value()); + EXPECT_TRUE(fs::exists(fs::symlink_status(root_ / "linked-orphan"))); + EXPECT_TRUE(fs::exists(root_ / "real-orphan" / "content.txt")); + } + } // namespace Test From 824034b0db91d433df6c5b7b6521ce9c28cea1e1 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:01:08 +0200 Subject: [PATCH 013/114] Told the owners of queued operations of their progress before their end - Progress reported while a batch works is held back and handed on before that batch's completions, instead of being posted behind them, so the last progress of an operation no longer arrives after its end. - Single transfers, archives and bulks call the progress observer beside the frontend event, on the strand. - dropAll forgets the observers of the dropped operations. --- .../source/backend/sftp/operation_queue.cpp | 192 +++++++++++++----- 1 file changed, 139 insertions(+), 53 deletions(-) diff --git a/backend/source/backend/sftp/operation_queue.cpp b/backend/source/backend/sftp/operation_queue.cpp index 1d5537fc..4906fe11 100644 --- a/backend/source/backend/sftp/operation_queue.cpp +++ b/backend/source/backend/sftp/operation_queue.cpp @@ -28,6 +28,8 @@ #include #include #include +#include +#include #include using namespace std::chrono_literals; @@ -68,6 +70,77 @@ namespace return parent ? *std::move(parent) : PathT{}; } + using ProgressObservers = std::unordered_map; + + /** + * @brief Holds back the progress the operations of a queue report while a batch of them works on this thread, so + * the queue hands it on before the completions of that batch instead of after them. + */ + class HeldProgress + { + public: + using Delivery = std::function; + + /** + * @brief Holds back the progress of the operations of @p queue in @p deliveries until destroyed. + */ + HeldProgress(OperationQueue const& queue, std::vector& deliveries) + : queue_{&queue} + , deliveries_{&deliveries} + , outer_{std::exchange(current_, this)} + {} + ~HeldProgress() + { + current_ = outer_; + } + HeldProgress(HeldProgress const&) = delete; + HeldProgress& operator=(HeldProgress const&) = delete; + HeldProgress(HeldProgress&&) = delete; + HeldProgress& operator=(HeldProgress&&) = delete; + + /** + * @brief Holds @p delivery back when a batch of @p queue works on this thread. + * @return False when none does, @p delivery is left as it was then. + */ + static bool holdBack(OperationQueue const& queue, Delivery& delivery) + { + if (current_ == nullptr || current_->queue_ != &queue) + return false; + current_->deliveries_->push_back(std::move(delivery)); + return true; + } + + private: + inline static thread_local HeldProgress* current_{nullptr}; + OperationQueue const* queue_; + std::vector* deliveries_; + HeldProgress* outer_; + }; + + /** + * @brief Runs @p delivery of progress on the strand of @p queue: after the batch it was reported in when that works + * on this thread, else as soon as the strand is free. + */ + void deliverProgress(OperationQueue const& queue, HeldProgress::Delivery delivery) + { + if (!HeldProgress::holdBack(queue, delivery)) + queue.within_strand_do(std::move(delivery)); + } + + /** + * @brief Tells the observer of the operation of @p progress in @p observers, if it has one. It runs as a copy, so + * it may end its operation, which drops it from @p observers. + */ + template + void tellObserver(ProgressObservers const& observers, ProgressT const& progress) + { + const auto found = observers.find(progress.operationId); + if (found == observers.end()) + return; + const auto observer = found->second; + observer(OperationQueue::OperationProgress{progress}); + } + bool changedSinceCompare(SharedData::OperationError const& error) { return error.type == SharedData::OperationErrorType::ChangedSinceCompare; @@ -270,8 +343,9 @@ auto OperationQueue::makeScanProgressCallback(std::string_view eventName, Ids::O if (!self) return; // May be invoked from the SFTP processing strand (batched umbrella), so the RPC call goes back onto the asio - // strand that owns the hub. - self->within_strand_do( + // strand that owns the hub, in order with the transfer progress of the same batch. + deliverProgress( + *self, [weak, name, operationId, totalBytes, currentIndex, totalScanned]() { auto self = weak.lock(); @@ -326,13 +400,15 @@ auto OperationQueue::makeBulkProgressCallback(std::string_view eventName, Ids::O // Invoked from the SFTP processing strand, marshal the RPC call back onto the asio // strand that owns the hub. progress.operationId = operationId; - self->within_strand_do( + deliverProgress( + *self, [weak, name, progress = std::move(progress)]() { auto self = weak.lock(); if (!self) return; RpcHelper::callRemoteSafely(*self->hub_, name, progress); + tellObserver(self->progressObservers_, progress); } ); }; @@ -371,6 +447,8 @@ void OperationQueue::dropAll() self->operations_.clear(); self->priorityOperations_.clear(); + // Progress they reported may still be on its way to the strand. + self->progressObservers_.clear(); auto waiting = std::exchange(self->syncScanCallbacks_, {}); for (auto& [id, callback] : waiting) callback(std::unexpected(Utility::localizedMessage("backend.sync.scanCancelled"))); @@ -791,8 +869,17 @@ bool OperationQueue::workQueue(std::deque onlyUsesStrand) + // Posted, the progress of the batch would reach the strand only after the completions handed on below. + std::vector heldProgress; + const auto deliverHeldProgress = [&heldProgress]() { + for (auto const& delivery : std::exchange(heldProgress, {})) + delivery(); + }; + + auto runBatch = [this, &queue, &eligible, &isTerminal, &heldProgress](std::optional onlyUsesStrand) + { + const HeldProgress holding{*this, heldProgress}; std::vector out; out.reserve(eligible.size()); for (const auto& item : eligible) @@ -833,7 +920,7 @@ bool OperationQueue::workQueue(std::dequestate() == SharedData::OperationState::Canceled @@ -879,6 +968,7 @@ bool OperationQueue::workQueue(std::deque OperationQueue::addDownloadOperation( if (!self) return; // Invoked from the SFTP processing strand via workInStrand, marshal back. - self->within_strand_do( + deliverProgress( + *self, [weak, name, operationId, min, max, current, bytesPerSecond]() { auto self = weak.lock(); if (!self) return; - RpcHelper::callRemoteSafely( - *self->hub_, - name, - SharedData::TransferProgress{ - .operationId = operationId, - .min = min, - .max = max, - .current = current, - .bytesPerSecond = bytesPerSecond, - } - ); + const SharedData::TransferProgress progress{ + .operationId = operationId, + .min = min, + .max = max, + .current = current, + .bytesPerSecond = bytesPerSecond, + }; + RpcHelper::callRemoteSafely(*self->hub_, name, progress); + tellObserver(self->progressObservers_, progress); } ); }; @@ -1290,23 +1379,22 @@ std::expected OperationQueue::addArchiveDownloadOperatio auto self = weak.lock(); if (!self) return; - self->within_strand_do( + deliverProgress( + *self, [weak, name, operationId, min, max, current, bytesPerSecond]() { auto self = weak.lock(); if (!self) return; - RpcHelper::callRemoteSafely( - *self->hub_, - name, - SharedData::TransferProgress{ - .operationId = operationId, - .min = min, - .max = max, - .current = current, - .bytesPerSecond = bytesPerSecond, - } - ); + const SharedData::TransferProgress progress{ + .operationId = operationId, + .min = min, + .max = max, + .current = current, + .bytesPerSecond = bytesPerSecond, + }; + RpcHelper::callRemoteSafely(*self->hub_, name, progress); + tellObserver(self->progressObservers_, progress); } ); }; @@ -1419,23 +1507,22 @@ std::expected OperationQueue::addArchiveUploadOperation( auto self = weak.lock(); if (!self) return; - self->within_strand_do( + deliverProgress( + *self, [weak, name, operationId, min, max, current, bytesPerSecond]() { auto self = weak.lock(); if (!self) return; - RpcHelper::callRemoteSafely( - *self->hub_, - name, - SharedData::TransferProgress{ - .operationId = operationId, - .min = min, - .max = max, - .current = current, - .bytesPerSecond = bytesPerSecond, - } - ); + const SharedData::TransferProgress progress{ + .operationId = operationId, + .min = min, + .max = max, + .current = current, + .bytesPerSecond = bytesPerSecond, + }; + RpcHelper::callRemoteSafely(*self->hub_, name, progress); + tellObserver(self->progressObservers_, progress); } ); }; @@ -1640,23 +1727,22 @@ std::expected OperationQueue::addUploadOperation( auto self = weak.lock(); if (!self) return; - self->within_strand_do( + deliverProgress( + *self, [weak, name, operationId, min, max, current, bytesPerSecond]() { auto self = weak.lock(); if (!self) return; - RpcHelper::callRemoteSafely( - *self->hub_, - name, - SharedData::TransferProgress{ - .operationId = operationId, - .min = min, - .max = max, - .current = current, - .bytesPerSecond = bytesPerSecond, - } - ); + const SharedData::TransferProgress progress{ + .operationId = operationId, + .min = min, + .max = max, + .current = current, + .bytesPerSecond = bytesPerSecond, + }; + RpcHelper::callRemoteSafely(*self->hub_, name, progress); + tellObserver(self->progressObservers_, progress); } ); }; From 83ac4d40c39e4d46b851149aba64f560aaa93aa0 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:01:49 +0200 Subject: [PATCH 014/114] Tested that queued transfers report their progress to their observers - A single upload and download, a directory upload and a bulk upload rise to their totals before their end. - Nothing reaches an observer after its end, also when it stops its own upload, and none hears another's progress. --- backend/test/backend/test_operation_queue.hpp | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) diff --git a/backend/test/backend/test_operation_queue.hpp b/backend/test/backend/test_operation_queue.hpp index 6f80cd8c..d9fd15ae 100644 --- a/backend/test/backend/test_operation_queue.hpp +++ b/backend/test/backend/test_operation_queue.hpp @@ -21,11 +21,14 @@ #include #include #include +#include #include #include #include #include +#include #include +#include #include #ifndef _WIN32 @@ -336,6 +339,133 @@ namespace Test ); } + /** + * @brief What the observer of the progress of one operation heard, on the strand. + */ + struct HeardProgress + { + std::vector progress{}; + bool completed{false}; + std::size_t afterCompletion{0}; + }; + + /** + * @brief Observes the progress and the end of @p operationId. Call on the strand, before it starts. + * @param onProgress Also hears each progress, after it was recorded. + */ + std::shared_ptr observeProgress( + Ids::OperationId const& operationId, + std::function onProgress = {} + ) + { + auto heard = std::make_shared(); + queue_->whenProgressing( + operationId, + [heard, onProgress = std::move(onProgress)](OperationQueue::OperationProgress const& progress) + { + if (heard->completed) + ++heard->afterCompletion; + heard->progress.push_back(progress); + if (onProgress) + onProgress(progress); + } + ); + queue_->whenCompleted( + operationId, + [heard](SharedData::OperationCompleted const&) + { + heard->completed = true; + } + ); + return heard; + } + + /** + * @brief A copy of @p heard taken on the strand, after a few more cycles of the queue that let progress still + * on its way arrive. + */ + HeardProgress heardAfterSettling(std::shared_ptr const& heard) + { + for (auto cycle = 0u; cycle < 5u; ++cycle) + { + runOnStrand( + [this]() + { + return queue_->work(); + } + ); + runOnStrand([]() {}); + } + return runOnStrand( + [&heard]() + { + return *heard; + } + ); + } + + /** + * @brief The progress in @p heard of the kind @p ProgressT, failing for any of another kind. + */ + template + static std::vector heardAs(HeardProgress const& heard) + { + std::vector progress; + for (auto const& each : heard.progress) + { + auto const* typed = std::get_if(&each); + EXPECT_NE(typed, nullptr) << "progress of another kind"; + if (typed) + progress.push_back(*typed); + } + return progress; + } + + /** + * @brief Expects @p progress of one file to rise from below @p totalBytes to it, reported for @p operationId. + */ + static void expectRisingTo( + std::vector const& progress, + Ids::OperationId const& operationId, + std::uint64_t totalBytes + ) + { + ASSERT_GE(progress.size(), 2u); + EXPECT_TRUE(std::ranges::is_sorted(progress, {}, &SharedData::TransferProgress::current)); + EXPECT_LT(progress.front().current, totalBytes); + EXPECT_EQ(progress.back().current, totalBytes); + for (auto const& each : progress) + { + EXPECT_EQ(each.operationId, operationId); + EXPECT_EQ(each.max, totalBytes); + } + } + + /** + * @brief Expects @p progress of a bulk to rise to @p entryCount entries of @p totalBytes, reported for + * @p operationId. + */ + static void expectRisingTo( + std::vector const& progress, + Ids::OperationId const& operationId, + std::uint64_t entryCount, + std::uint64_t totalBytes + ) + { + ASSERT_GE(progress.size(), 2u); + EXPECT_TRUE(std::ranges::is_sorted(progress, {}, &SharedData::BulkProgress::bytesCurrent)); + EXPECT_TRUE(std::ranges::is_sorted(progress, {}, &SharedData::BulkProgress::fileCurrentIndex)); + EXPECT_LT(progress.front().bytesCurrent, totalBytes); + EXPECT_EQ(progress.back().bytesCurrent, totalBytes); + EXPECT_EQ(progress.back().fileCurrentIndex, entryCount); + for (auto const& each : progress) + { + EXPECT_EQ(each.operationId, operationId); + EXPECT_EQ(each.bytesTotal, totalBytes); + EXPECT_EQ(each.fileCount, entryCount); + } + } + protected: boost::asio::thread_pool queuePool_{2}; std::shared_ptr> strand_{ @@ -1271,6 +1401,258 @@ namespace Test EXPECT_EQ(called.front().reason, SharedData::OperationCompletionReason::Failed); } + TEST_F(OperationQueueTests, ASingleUploadTellsItsObserverHowFarItGotUntilItsEnd) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + // Above the big-file cutoff, so it reports before its last byte went too. + const auto localFile = localDirectory_.path() / "observed.bin"; + const auto size = 64u * 1024u; + std::ofstream{localFile, std::ios::binary} << std::string(size, 'x'); + const auto upload = Ids::generateOperationId(); + ASSERT_TRUE(runOnStrand( + [&]() + { + return queue->addUploadOperation( + *sftp, upload, toLocal(localFile), remote("/home/test/observed.bin"), false, false, false, false + ); + } + ).has_value()); + const auto observed = runOnStrand( + [&]() + { + return observeProgress(upload); + } + ); + + workUntilCompletions(1); + const auto heard = heardAfterSettling(observed); + ASSERT_EQ(completions().size(), 1u); + EXPECT_EQ(completions().front().reason, SharedData::OperationCompletionReason::Completed); + EXPECT_TRUE(heard.completed); + EXPECT_EQ(heard.afterCompletion, 0u); + expectRisingTo(heardAs(heard), upload, size); + } + + TEST_F(OperationQueueTests, ASingleDownloadTellsItsObserverHowFarItGotUntilItsEnd) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + // The fixture above the big-file cutoff. + const auto size = 65536u; + const auto download = Ids::generateOperationId(); + ASSERT_TRUE(runOnStrand( + [&]() + { + return queue->addDownloadOperation( + *sftp, + download, + toLocal(localDirectory_.path() / "large.txt"), + remote("/home/test/large.txt"), + false, + false, + false, + false + ); + } + ).has_value()); + const auto observed = runOnStrand( + [&]() + { + return observeProgress(download); + } + ); + + workUntilCompletions(1); + const auto heard = heardAfterSettling(observed); + ASSERT_EQ(completions().size(), 1u); + EXPECT_EQ(completions().front().reason, SharedData::OperationCompletionReason::Completed); + EXPECT_EQ(heard.afterCompletion, 0u); + expectRisingTo(heardAs(heard), download, size); + } + + TEST_F(OperationQueueTests, ADirectoryUploadTellsItsObserverHowFarItGotUntilItsEnd) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + const auto localPath = localDirectory_.path() / "observedDirectory"; + std::filesystem::create_directories(localPath); + std::ofstream{localPath / "a.txt"} << "a"; + std::ofstream{localPath / "b.txt"} << "bb"; + const auto upload = Ids::generateOperationId(); + ASSERT_TRUE(runOnStrand( + [&]() + { + return queue->addUploadOperation( + *sftp, + upload, + toLocal(localPath), + remote("/home/test/observedDirectory"), + false, + false, + false, + false + ); + } + ).has_value()); + const auto observed = runOnStrand( + [&]() + { + return observeProgress(upload); + } + ); + + workUntilCompletions(1); + const auto heard = heardAfterSettling(observed); + ASSERT_EQ(completions().size(), 1u); + EXPECT_EQ(completions().front().reason, SharedData::OperationCompletionReason::Completed); + EXPECT_TRUE(heard.completed); + EXPECT_EQ(heard.afterCompletion, 0u); + expectRisingTo(heardAs(heard), upload, 2u, 3u); + } + + TEST_F(OperationQueueTests, ABulkUploadTellsTheObserverOfItsCardHowFarItGotUntilItsEnd) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + const auto localPath = localDirectory_.path() / "observedBulk"; + std::filesystem::create_directories(localPath); + std::ofstream{localPath / "a.txt"} << "a"; + std::ofstream{localPath / "b.txt"} << "bb"; + const SharedData::BulkUploadRequest request{ + .entries = { + {.source = toLocal(localPath / "a.txt"), .destination = remote("/home/test/bulkA.txt"), .sizeBytes = 1}, + {.source = toLocal(localPath / "b.txt"), .destination = remote("/home/test/bulkB.txt"), .sizeBytes = 2}, + }, + }; + const auto card = Ids::generateOperationId(); + const auto observed = runOnStrand( + [&]() + { + queue->addBulkUploadOperation( + *sftp, + request, + [](std::size_t) + { + return Ids::generateOperationId(); + }, + card + ); + return observeProgress(card); + } + ); + + workUntilCompletions(1); + const auto heard = heardAfterSettling(observed); + ASSERT_EQ(completions().size(), 1u); + EXPECT_EQ(completions().front().reason, SharedData::OperationCompletionReason::Completed); + EXPECT_TRUE(completions().front().failedEntries.empty()); + EXPECT_TRUE(heard.completed); + EXPECT_EQ(heard.afterCompletion, 0u); + expectRisingTo(heardAs(heard), card, 2u, 3u); + } + + TEST_F(OperationQueueTests, AnObserverStoppingItsUploadHearsNothingAfterTheEnd) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + const auto localFile = localDirectory_.path() / "stopped.bin"; + std::ofstream{localFile, std::ios::binary} << std::string(64u * 1024u, 'x'); + const auto upload = Ids::generateOperationId(); + ASSERT_TRUE(runOnStrand( + [&]() + { + return queue->addUploadOperation( + *sftp, upload, toLocal(localFile), remote("/home/test/stopped.bin"), false, false, false, false + ); + } + ).has_value()); + // Its end is handed on while the observer runs, which drops the observer from the queue. + const auto observed = runOnStrand( + [&]() + { + return observeProgress( + upload, + [weak = std::weak_ptr{queue}, upload](OperationQueue::OperationProgress const&) + { + if (const auto owner = weak.lock()) + owner->cancel(upload); + } + ); + } + ); + + workUntilCompletions(1); + const auto heard = heardAfterSettling(observed); + ASSERT_EQ(completions().size(), 1u); + EXPECT_EQ(completions().front().reason, SharedData::OperationCompletionReason::Canceled); + EXPECT_TRUE(heard.completed); + EXPECT_EQ(heard.progress.size(), 1u); + EXPECT_EQ(heard.afterCompletion, 0u); + } + + TEST_F(OperationQueueTests, AnObserverHearsTheProgressOfItsOwnOperationOnly) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + // The queue has two slots, so both run in the same batches. + const auto size = 64u * 1024u; + const std::vector names{"first.bin", "second.bin"}; + std::vector uploads; + for (auto const& name : names) + { + std::ofstream{localDirectory_.path() / name, std::ios::binary} << std::string(size, 'x'); + uploads.push_back(Ids::generateOperationId()); + ASSERT_TRUE(runOnStrand( + [&]() + { + return queue->addUploadOperation( + *sftp, + uploads.back(), + toLocal(localDirectory_.path() / name), + remote(fmt::format("/home/test/{}", name)), + false, + false, + false, + false + ); + } + ).has_value()); + } + const auto stranger = Ids::generateOperationId(); + const auto [first, second, unrelated] = runOnStrand( + [&]() + { + return std::tuple{observeProgress(uploads[0]), observeProgress(uploads[1]), observeProgress(stranger)}; + } + ); + + workUntilCompletions(2); + expectRisingTo(heardAs(heardAfterSettling(first)), uploads[0], size); + expectRisingTo(heardAs(heardAfterSettling(second)), uploads[1], size); + const auto heardByStranger = heardAfterSettling(unrelated); + EXPECT_TRUE(heardByStranger.progress.empty()); + EXPECT_FALSE(heardByStranger.completed); + } + TEST_F(OperationQueueTests, AdoptedDirectoryDownloadKeepsItsCardId) { CREATE_SERVER_AND_JOINER(sftpServer); From bcf88abb957cdd27468e61f6a56610b3875b3d87 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:56:34 +0200 Subject: [PATCH 015/114] Listed conflicts per watch and path with the context to answer them --- .../backend/file_tracking/conflict_list.hpp | 210 +++++++++++++ backend/test/backend/test_conflict_list.hpp | 291 ++++++++++++++++++ 2 files changed, 501 insertions(+) diff --git a/backend/include/backend/file_tracking/conflict_list.hpp b/backend/include/backend/file_tracking/conflict_list.hpp index 6f70f09b..6797b642 100644 --- a/backend/include/backend/file_tracking/conflict_list.hpp +++ b/backend/include/backend/file_tracking/conflict_list.hpp @@ -1 +1,211 @@ #pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The conflicts that wait for the user's answer, any number per watch but one per watch and local path. + * Each keeps a @p Context next to the shared conflict: what the tracker answers with, the upload that + * waits for example. Plain bookkeeping, it tells nobody. + */ + template + class ConflictList + { + public: + /** + * @brief A listed conflict with its context. + */ + struct Listed + { + SharedData::WatchConflict conflict; + Context context; + }; + + /** + * @brief What add() listed. + */ + struct Added + { + /** @brief The conflict as listed, with the id it got. */ + SharedData::WatchConflict conflict; + /** @brief The context of the conflict it replaced, nothing when it is new. */ + std::optional replaced{}; + }; + + /** + * @brief Lists @p conflict of its watch and local path with @p context. A conflict listed there already is + * replaced: the new one keeps its id and arrives anew, behind the others of the watch. A new one gets a + * new id, the id of @p conflict is not used. + */ + Added add(SharedData::WatchConflict conflict, Context context) + { + auto& index = watches_[conflict.watchId]; + std::optional replaced{}; + if (const auto listed = index.byPath.find(conflict.localPath); listed != index.byPath.end()) + { + auto node = conflicts_.extract(listed->second); + index.byArrival.erase(node.mapped().arrival); + conflict.conflictId = node.key(); + replaced = std::move(node.mapped().listed.context); + } + else + conflict.conflictId = Ids::generateConflictId(); + const auto arrival = nextArrival_++; + index.byPath.insert_or_assign(conflict.localPath, conflict.conflictId); + index.byArrival.emplace(arrival, conflict.conflictId); + conflicts_.emplace( + conflict.conflictId, + Stored{.listed = Listed{.conflict = conflict, .context = std::move(context)}, .arrival = arrival} + ); + return Added{.conflict = std::move(conflict), .replaced = std::move(replaced)}; + } + + /** + * @brief The conflict @p conflictId, nothing when it is not listed. + */ + Listed const* find(Ids::ConflictId const& conflictId) const + { + const auto found = conflicts_.find(conflictId); + return found == conflicts_.end() ? nullptr : &found->second.listed; + } + + /** + * @brief The conflict of the watch @p watchId at @p localPath, nothing when none is listed there. + */ + Listed const* find(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) const + { + const auto* index = indexOf(watchId); + if (!index) + return nullptr; + const auto found = index->byPath.find(localPath); + return found == index->byPath.end() ? nullptr : find(found->second); + } + + /** + * @brief The conflicts of the watch @p watchId in the order they arrived. + */ + std::vector conflictsOf(Ids::WatchId const& watchId) const + { + std::vector listed; + if (const auto* index = indexOf(watchId)) + for (auto const& conflictId : index->byArrival | std::views::values) + listed.push_back(conflicts_.at(conflictId).listed.conflict); + return listed; + } + + /** + * @brief How many conflicts of the watch @p watchId wait for an answer. + */ + std::uint64_t countOf(Ids::WatchId const& watchId) const + { + const auto* index = indexOf(watchId); + return index ? static_cast(index->byArrival.size()) : 0u; + } + + std::uint64_t size() const + { + return static_cast(conflicts_.size()); + } + + bool empty() const + { + return conflicts_.empty(); + } + + /** + * @brief Takes the conflict @p conflictId off the list. + * @return It, nothing when it was not listed. + */ + std::optional remove(Ids::ConflictId const& conflictId) + { + auto node = conflicts_.extract(conflictId); + if (node.empty()) + return std::nullopt; + unindex(node.mapped()); + return std::move(node.mapped().listed); + } + + /** + * @brief Takes every conflict of the watch @p watchId off the list. + * @return They, in the order they arrived. + */ + std::vector removeWatch(Ids::WatchId const& watchId) + { + auto node = watches_.extract(watchId); + std::vector removed; + if (node.empty()) + return removed; + for (auto const& conflictId : node.mapped().byArrival | std::views::values) + removed.push_back(std::move(conflicts_.extract(conflictId).mapped().listed)); + return removed; + } + + /** + * @brief Takes every conflict off the list. + * @return They, in the order they arrived. + */ + std::vector removeAll() + { + auto conflicts = std::exchange(conflicts_, {}); + watches_.clear(); + std::vector stored; + stored.reserve(conflicts.size()); + for (auto& listed : conflicts | std::views::values) + stored.push_back(std::move(listed)); + std::ranges::sort(stored, std::ranges::less{}, &Stored::arrival); + std::vector removed; + removed.reserve(stored.size()); + for (auto& listed : stored) + removed.push_back(std::move(listed.listed)); + return removed; + } + + private: + struct Stored + { + Listed listed; + std::uint64_t arrival{0}; + }; + + /** + * @brief The conflicts of one watch by local path and in the order they arrived. + */ + struct WatchIndex + { + std::map byPath{}; + std::map byArrival{}; + }; + + WatchIndex const* indexOf(Ids::WatchId const& watchId) const + { + const auto found = watches_.find(watchId); + return found == watches_.end() ? nullptr : &found->second; + } + + void unindex(Stored const& stored) + { + const auto index = watches_.find(stored.listed.conflict.watchId); + if (index == watches_.end()) + return; + index->second.byPath.erase(stored.listed.conflict.localPath); + index->second.byArrival.erase(stored.arrival); + if (index->second.byArrival.empty()) + watches_.erase(index); + } + + std::map conflicts_{}; + std::map watches_{}; + std::uint64_t nextArrival_{0}; + }; +} diff --git a/backend/test/backend/test_conflict_list.hpp b/backend/test/backend/test_conflict_list.hpp index 6f70f09b..e3aba3b9 100644 --- a/backend/test/backend/test_conflict_list.hpp +++ b/backend/test/backend/test_conflict_list.hpp @@ -1 +1,292 @@ #pragma once + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace Test::ConflictListCases +{ + /** + * @brief Stands for what the tracker keeps with a conflict. + */ + struct WaitingUpload + { + std::string label{}; + }; + + using Conflicts = FileTracking::ConflictList; + + inline Paths::LocalPath localPathOf(std::string_view path) + { + return Paths::LocalPath::fromUtf8(path, Paths::LocalPlatform::Posix).value(); + } + + inline SharedData::WatchConflict conflictAt( + Ids::WatchId const& watchId, + std::string_view path, + SharedData::ConflictKind kind = SharedData::ConflictKind::ChangedOnServer, + std::uint64_t raisedAt = 100u + ) + { + return SharedData::WatchConflict{ + .conflictId = Ids::makeConflictId("chosen-by-the-caller"), + .watchId = watchId, + .kind = kind, + .localPath = localPathOf(path), + .remotePath = Paths::RemotePath::make(fmt::format("/srv{}", path)).value(), + .serverModifiedAt = 50u, + .localModifiedAt = 60u, + .raisedAt = raisedAt, + }; + } + + inline std::vector pathsOf(std::vector const& conflicts) + { + std::vector paths; + for (auto const& conflict : conflicts) + paths.push_back(conflict.localPath.toUtf8()); + return paths; + } + + inline std::vector labelsOf(std::vector const& listed) + { + std::vector labels; + for (auto const& entry : listed) + labels.push_back(entry.context.label); + return labels; + } + + class ConflictListTest : public ::testing::Test + { + protected: + Conflicts conflicts_{}; + Ids::WatchId watch_{Ids::makeWatchId("watch-one")}; + Ids::WatchId otherWatch_{Ids::makeWatchId("watch-two")}; + }; + + TEST_F(ConflictListTest, ANewConflictGetsAnIdOfItsOwnAndIsFoundByIdAndByPath) + { + const auto added = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{.label = "a"}); + + EXPECT_TRUE(added.conflict.conflictId.isValid()); + EXPECT_NE(added.conflict.conflictId.value(), "chosen-by-the-caller"); + EXPECT_FALSE(added.replaced.has_value()); + + const auto* byId = conflicts_.find(added.conflict.conflictId); + ASSERT_NE(byId, nullptr); + EXPECT_EQ(byId->context.label, "a"); + EXPECT_EQ(byId->conflict.conflictId, added.conflict.conflictId); + + const auto* byPath = conflicts_.find(watch_, localPathOf("/watched/a.txt")); + ASSERT_NE(byPath, nullptr); + EXPECT_EQ(byPath->conflict.conflictId, added.conflict.conflictId); + + EXPECT_EQ(conflicts_.size(), 1u); + EXPECT_FALSE(conflicts_.empty()); + EXPECT_EQ(conflicts_.countOf(watch_), 1u); + } + + TEST_F(ConflictListTest, AListedConflictKeepsWhatItWasAddedWith) + { + const auto added = conflicts_.add( + conflictAt(watch_, "/watched/a.txt", SharedData::ConflictKind::DeletedOnServer, 123u), WaitingUpload{} + ); + + const auto* listed = conflicts_.find(added.conflict.conflictId); + ASSERT_NE(listed, nullptr); + EXPECT_EQ(listed->conflict.watchId, watch_); + EXPECT_EQ(listed->conflict.kind, SharedData::ConflictKind::DeletedOnServer); + EXPECT_EQ(listed->conflict.localPath.toUtf8(), "/watched/a.txt"); + EXPECT_EQ(listed->conflict.remotePath.value(), "/srv/watched/a.txt"); + EXPECT_EQ(listed->conflict.serverModifiedAt, std::optional{50u}); + EXPECT_EQ(listed->conflict.localModifiedAt, std::optional{60u}); + EXPECT_EQ(listed->conflict.raisedAt, 123u); + } + + TEST_F(ConflictListTest, ANewerConflictOfTheSamePathReplacesItAndKeepsItsId) + { + const auto first = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{.label = "first"}); + const auto second = conflicts_.add( + conflictAt(watch_, "/watched/a.txt", SharedData::ConflictKind::CreatedOnServer, 200u), + WaitingUpload{.label = "second"} + ); + + EXPECT_EQ(second.conflict.conflictId, first.conflict.conflictId); + ASSERT_TRUE(second.replaced.has_value()); + EXPECT_EQ(second.replaced->label, "first"); + + const auto* listed = conflicts_.find(first.conflict.conflictId); + ASSERT_NE(listed, nullptr); + EXPECT_EQ(listed->context.label, "second"); + EXPECT_EQ(listed->conflict.kind, SharedData::ConflictKind::CreatedOnServer); + EXPECT_EQ(listed->conflict.raisedAt, 200u); + EXPECT_EQ(conflicts_.size(), 1u); + EXPECT_EQ(conflicts_.countOf(watch_), 1u); + } + + TEST_F(ConflictListTest, TheSamePathOfAnotherWatchIsAConflictOfItsOwn) + { + const auto mine = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{.label = "mine"}); + const auto other = conflicts_.add(conflictAt(otherWatch_, "/watched/a.txt"), WaitingUpload{.label = "other"}); + + EXPECT_NE(mine.conflict.conflictId, other.conflict.conflictId); + EXPECT_FALSE(other.replaced.has_value()); + EXPECT_EQ(conflicts_.countOf(watch_), 1u); + EXPECT_EQ(conflicts_.countOf(otherWatch_), 1u); + const auto* listed = conflicts_.find(otherWatch_, localPathOf("/watched/a.txt")); + ASSERT_NE(listed, nullptr); + EXPECT_EQ(listed->context.label, "other"); + } + + TEST_F(ConflictListTest, TheConflictsOfAWatchComeInTheOrderTheyArrived) + { + conflicts_.add(conflictAt(watch_, "/watched/c.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(otherWatch_, "/other/x.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{}); + + EXPECT_EQ( + pathsOf(conflicts_.conflictsOf(watch_)), + (std::vector{"/watched/c.txt", "/watched/a.txt", "/watched/b.txt"}) + ); + EXPECT_EQ(pathsOf(conflicts_.conflictsOf(otherWatch_)), (std::vector{"/other/x.txt"})); + } + + TEST_F(ConflictListTest, AReplacedConflictArrivesAnewBehindTheOthers) + { + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/c.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + + EXPECT_EQ( + pathsOf(conflicts_.conflictsOf(watch_)), + (std::vector{"/watched/b.txt", "/watched/c.txt", "/watched/a.txt"}) + ); + } + + TEST_F(ConflictListTest, RemovingAConflictByIdHandsBackItsContext) + { + const auto kept = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{.label = "kept"}); + const auto added = conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{.label = "answered"}); + + const auto removed = conflicts_.remove(added.conflict.conflictId); + + ASSERT_TRUE(removed.has_value()); + EXPECT_EQ(removed->context.label, "answered"); + EXPECT_EQ(removed->conflict.conflictId, added.conflict.conflictId); + EXPECT_EQ(conflicts_.find(added.conflict.conflictId), nullptr); + EXPECT_EQ(conflicts_.find(watch_, localPathOf("/watched/b.txt")), nullptr); + EXPECT_NE(conflicts_.find(kept.conflict.conflictId), nullptr); + EXPECT_EQ(conflicts_.countOf(watch_), 1u); + EXPECT_EQ(pathsOf(conflicts_.conflictsOf(watch_)), (std::vector{"/watched/a.txt"})); + } + + TEST_F(ConflictListTest, RemovingAConflictTwiceOrAnUnknownOneRemovesNothing) + { + const auto added = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + + EXPECT_FALSE(conflicts_.remove(Ids::makeConflictId("unknown")).has_value()); + EXPECT_EQ(conflicts_.size(), 1u); + EXPECT_TRUE(conflicts_.remove(added.conflict.conflictId).has_value()); + EXPECT_FALSE(conflicts_.remove(added.conflict.conflictId).has_value()); + EXPECT_TRUE(conflicts_.empty()); + EXPECT_EQ(conflicts_.countOf(watch_), 0u); + EXPECT_TRUE(conflicts_.conflictsOf(watch_).empty()); + } + + TEST_F(ConflictListTest, ARemovedPathGetsANewIdWhenItConflictsAgain) + { + const auto first = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + conflicts_.remove(first.conflict.conflictId); + + const auto again = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + + EXPECT_NE(again.conflict.conflictId, first.conflict.conflictId); + EXPECT_FALSE(again.replaced.has_value()); + } + + TEST_F(ConflictListTest, DroppingAWatchRemovesOnlyItsConflictsInTheOrderTheyArrived) + { + const auto first = conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{.label = "first"}); + const auto other = conflicts_.add(conflictAt(otherWatch_, "/other/x.txt"), WaitingUpload{.label = "other"}); + const auto second = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{.label = "second"}); + + const auto dropped = conflicts_.removeWatch(watch_); + + EXPECT_EQ(labelsOf(dropped), (std::vector{"first", "second"})); + ASSERT_EQ(dropped.size(), 2u); + EXPECT_EQ(dropped[0].conflict.conflictId, first.conflict.conflictId); + EXPECT_EQ(dropped[1].conflict.conflictId, second.conflict.conflictId); + EXPECT_EQ(conflicts_.find(first.conflict.conflictId), nullptr); + EXPECT_EQ(conflicts_.find(watch_, localPathOf("/watched/a.txt")), nullptr); + EXPECT_EQ(conflicts_.countOf(watch_), 0u); + EXPECT_TRUE(conflicts_.conflictsOf(watch_).empty()); + EXPECT_NE(conflicts_.find(other.conflict.conflictId), nullptr); + EXPECT_EQ(conflicts_.countOf(otherWatch_), 1u); + EXPECT_EQ(conflicts_.size(), 1u); + EXPECT_TRUE(conflicts_.removeWatch(watch_).empty()); + } + + TEST_F(ConflictListTest, ADroppedWatchStartsAfreshWithNewIds) + { + const auto before = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + conflicts_.removeWatch(watch_); + + const auto after = conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + + EXPECT_NE(after.conflict.conflictId, before.conflict.conflictId); + EXPECT_FALSE(after.replaced.has_value()); + EXPECT_EQ(conflicts_.countOf(watch_), 1u); + } + + TEST_F(ConflictListTest, RemovingAllHandsBackEveryConflictInTheOrderTheyArrived) + { + conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{.label = "first"}); + conflicts_.add(conflictAt(otherWatch_, "/other/x.txt"), WaitingUpload{.label = "second"}); + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{.label = "third"}); + conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{.label = "fourth"}); + + const auto removed = conflicts_.removeAll(); + + EXPECT_EQ(labelsOf(removed), (std::vector{"second", "third", "fourth"})); + EXPECT_TRUE(conflicts_.empty()); + EXPECT_EQ(conflicts_.size(), 0u); + EXPECT_EQ(conflicts_.countOf(watch_), 0u); + EXPECT_EQ(conflicts_.countOf(otherWatch_), 0u); + EXPECT_TRUE(conflicts_.removeAll().empty()); + } + + TEST_F(ConflictListTest, CountsAreKeptPerWatch) + { + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/b.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + conflicts_.add(conflictAt(otherWatch_, "/other/x.txt"), WaitingUpload{}); + + EXPECT_EQ(conflicts_.countOf(watch_), 2u); + EXPECT_EQ(conflicts_.countOf(otherWatch_), 1u); + EXPECT_EQ(conflicts_.countOf(Ids::makeWatchId("unknown")), 0u); + EXPECT_EQ(conflicts_.size(), 3u); + } + + TEST_F(ConflictListTest, NothingIsFoundForAnUnknownWatchPathOrId) + { + conflicts_.add(conflictAt(watch_, "/watched/a.txt"), WaitingUpload{}); + + EXPECT_EQ(conflicts_.find(Ids::makeConflictId("unknown")), nullptr); + EXPECT_EQ(conflicts_.find(watch_, localPathOf("/watched/b.txt")), nullptr); + EXPECT_EQ(conflicts_.find(otherWatch_, localPathOf("/watched/a.txt")), nullptr); + EXPECT_TRUE(conflicts_.conflictsOf(otherWatch_).empty()); + } +} From d736bdd3b85991d08da210f200080d4d5ca9b523 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:57:49 +0200 Subject: [PATCH 016/114] Listed failed uploads per watch and path, counting attempts of a path --- .../backend/file_tracking/failure_list.hpp | 220 ++++++++++++++ backend/test/backend/test_failure_list.hpp | 282 ++++++++++++++++++ 2 files changed, 502 insertions(+) diff --git a/backend/include/backend/file_tracking/failure_list.hpp b/backend/include/backend/file_tracking/failure_list.hpp index 6f70f09b..8402e321 100644 --- a/backend/include/backend/file_tracking/failure_list.hpp +++ b/backend/include/backend/file_tracking/failure_list.hpp @@ -1 +1,221 @@ #pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The failed uploads of the watches, one per watch and local path, in the order their paths first failed. + * They wait for Retry or Dismiss, nothing retries them. Plain bookkeeping, it tells nobody. + */ + class FailureList + { + public: + /** + * @brief Lists @p failure of its watch and local path. A path that failed before keeps its id and place, + * takes the reason, detail, remote path and failedAt of @p failure, and counts one attempt more. A new + * one gets a new id and one attempt, those of @p failure are not used. + * @return The failure as listed. + */ + SharedData::UploadFailure record(SharedData::UploadFailure failure) + { + auto& index = watches_[failure.watchId]; + if (const auto listed = index.byPath.find(failure.localPath); listed != index.byPath.end()) + { + auto& stored = failures_.at(listed->second).failure; + failure.failureId = stored.failureId; + failure.attempts = stored.attempts + 1u; + stored = failure; + return failure; + } + failure.failureId = Ids::generateFailureId(); + failure.attempts = 1u; + const auto arrival = nextArrival_++; + index.byPath.emplace(failure.localPath, failure.failureId); + index.byArrival.emplace(arrival, failure.failureId); + failures_.emplace(failure.failureId, Stored{.failure = failure, .arrival = arrival}); + return failure; + } + + /** + * @brief The failure @p failureId, nothing when it is not listed. + */ + SharedData::UploadFailure const* find(Ids::FailureId const& failureId) const + { + const auto found = failures_.find(failureId); + return found == failures_.end() ? nullptr : &found->second.failure; + } + + /** + * @brief The failure of the watch @p watchId at @p localPath, nothing when none is listed there. + */ + SharedData::UploadFailure const* find(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) const + { + const auto* index = indexOf(watchId); + if (!index) + return nullptr; + const auto found = index->byPath.find(localPath); + return found == index->byPath.end() ? nullptr : find(found->second); + } + + /** + * @brief The failures of the watch @p watchId in the order their paths first failed. + */ + std::vector failuresOf(Ids::WatchId const& watchId) const + { + std::vector listed; + if (const auto* index = indexOf(watchId)) + for (auto const& failureId : index->byArrival | std::views::values) + listed.push_back(failures_.at(failureId).failure); + return listed; + } + + std::uint64_t countOf(Ids::WatchId const& watchId) const + { + const auto* index = indexOf(watchId); + return index ? static_cast(index->byArrival.size()) : 0u; + } + + std::uint64_t size() const + { + return static_cast(failures_.size()); + } + + bool empty() const + { + return failures_.empty(); + } + + /** + * @brief Takes the failure @p failureId off the list, on Dismiss. + * @return It, nothing when it was not listed. + */ + std::optional remove(Ids::FailureId const& failureId) + { + auto node = failures_.extract(failureId); + if (node.empty()) + return std::nullopt; + unindex(node.mapped()); + return std::move(node.mapped().failure); + } + + /** + * @brief Takes the failure of the watch @p watchId at @p localPath off the list, once that path uploaded. + * @return It, nothing when none was listed there. + */ + std::optional + removePath(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) + { + const auto* failure = find(watchId, localPath); + if (!failure) + return std::nullopt; + const auto failureId = failure->failureId; + return remove(failureId); + } + + /** + * @brief Takes the failures of the watch @p watchId at @p directory and below it off the list, once a + * directory upload went through, but for the paths in @p keep: those it left out. + * @return They, in the order their paths first failed. + */ + std::vector removeWithin( + Ids::WatchId const& watchId, + Paths::LocalPath const& directory, + std::set const& keep = {} + ) + { + std::vector within; + if (const auto* index = indexOf(watchId)) + for (auto const& failureId : index->byArrival | std::views::values) + { + auto const& path = failures_.at(failureId).failure.localPath; + if (path.isWithin(directory) && !keep.contains(path)) + within.push_back(failureId); + } + std::vector removed; + for (auto const& failureId : within) + removed.push_back(*remove(failureId)); + return removed; + } + + /** + * @brief Takes every failure of the watch @p watchId off the list. + * @return They, in the order their paths first failed. + */ + std::vector removeWatch(Ids::WatchId const& watchId) + { + auto node = watches_.extract(watchId); + std::vector removed; + if (node.empty()) + return removed; + for (auto const& failureId : node.mapped().byArrival | std::views::values) + removed.push_back(std::move(failures_.extract(failureId).mapped().failure)); + return removed; + } + + /** + * @brief Takes every failure off the list. + * @return They, in the order their paths first failed. + */ + std::vector removeAll() + { + auto failures = std::exchange(failures_, {}); + watches_.clear(); + std::map byArrival; + for (auto& stored : failures | std::views::values) + byArrival.emplace(stored.arrival, std::move(stored.failure)); + std::vector removed; + removed.reserve(byArrival.size()); + for (auto& failure : byArrival | std::views::values) + removed.push_back(std::move(failure)); + return removed; + } + + private: + struct Stored + { + SharedData::UploadFailure failure; + std::uint64_t arrival{0}; + }; + + /** + * @brief The failures of one watch by local path and in the order their paths first failed. + */ + struct WatchIndex + { + std::map byPath{}; + std::map byArrival{}; + }; + + WatchIndex const* indexOf(Ids::WatchId const& watchId) const + { + const auto found = watches_.find(watchId); + return found == watches_.end() ? nullptr : &found->second; + } + + void unindex(Stored const& stored) + { + const auto index = watches_.find(stored.failure.watchId); + if (index == watches_.end()) + return; + index->second.byPath.erase(stored.failure.localPath); + index->second.byArrival.erase(stored.arrival); + if (index->second.byArrival.empty()) + watches_.erase(index); + } + + std::map failures_{}; + std::map watches_{}; + std::uint64_t nextArrival_{0}; + }; +} diff --git a/backend/test/backend/test_failure_list.hpp b/backend/test/backend/test_failure_list.hpp index 6f70f09b..d190b392 100644 --- a/backend/test/backend/test_failure_list.hpp +++ b/backend/test/backend/test_failure_list.hpp @@ -1 +1,283 @@ #pragma once + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Test::FailureListCases +{ + inline Paths::LocalPath localPathOf(std::string_view path) + { + return Paths::LocalPath::fromUtf8(path, Paths::LocalPlatform::Posix).value(); + } + + inline SharedData::UploadFailure failureAt( + Ids::WatchId const& watchId, + std::string_view path, + SharedData::UploadFailureReason reason = SharedData::UploadFailureReason::Unknown, + std::uint64_t failedAt = 10u, + std::optional detail = std::nullopt + ) + { + return SharedData::UploadFailure{ + .failureId = Ids::makeFailureId("chosen-by-the-caller"), + .watchId = watchId, + .localPath = localPathOf(path), + .remotePath = Paths::RemotePath::make(fmt::format("/srv{}", path)).value(), + .reason = reason, + .detail = std::move(detail), + .failedAt = failedAt, + .attempts = 7u, + }; + } + + inline std::vector pathsOf(std::vector const& failures) + { + std::vector paths; + for (auto const& failure : failures) + paths.push_back(failure.localPath.toUtf8()); + return paths; + } + + class FailureListTest : public ::testing::Test + { + protected: + FileTracking::FailureList failures_{}; + Ids::WatchId watch_{Ids::makeWatchId("watch-one")}; + Ids::WatchId otherWatch_{Ids::makeWatchId("watch-two")}; + }; + + TEST_F(FailureListTest, ANewFailureGetsAnIdOfItsOwnAndOneAttempt) + { + const auto recorded = failures_.record(failureAt(watch_, "/watched/a.txt")); + + EXPECT_TRUE(recorded.failureId.isValid()); + EXPECT_NE(recorded.failureId.value(), "chosen-by-the-caller"); + EXPECT_EQ(recorded.attempts, 1u); + + const auto* byId = failures_.find(recorded.failureId); + ASSERT_NE(byId, nullptr); + EXPECT_EQ(byId->localPath.toUtf8(), "/watched/a.txt"); + EXPECT_EQ(byId->attempts, 1u); + + const auto* byPath = failures_.find(watch_, localPathOf("/watched/a.txt")); + ASSERT_NE(byPath, nullptr); + EXPECT_EQ(byPath->failureId, recorded.failureId); + + EXPECT_EQ(failures_.size(), 1u); + EXPECT_FALSE(failures_.empty()); + EXPECT_EQ(failures_.countOf(watch_), 1u); + } + + TEST_F(FailureListTest, TheSamePathFailingAgainKeepsItsIdTakesTheNewReasonAndCountsAttempts) + { + const auto first = failures_.record( + failureAt(watch_, "/watched/a.txt", SharedData::UploadFailureReason::LockedLocally, 10u, "busy") + ); + auto again = failureAt(watch_, "/watched/a.txt", SharedData::UploadFailureReason::ServerRefused, 20u, "denied"); + again.remotePath = Paths::RemotePath::make("/srv/renamed/a.txt").value(); + const auto second = failures_.record(std::move(again)); + + EXPECT_EQ(second.failureId, first.failureId); + EXPECT_EQ(second.attempts, 2u); + EXPECT_EQ(second.reason, SharedData::UploadFailureReason::ServerRefused); + EXPECT_EQ(second.detail, std::optional{"denied"}); + EXPECT_EQ(second.failedAt, 20u); + EXPECT_EQ(second.remotePath.value(), "/srv/renamed/a.txt"); + + const auto* listed = failures_.find(first.failureId); + ASSERT_NE(listed, nullptr); + EXPECT_EQ(listed->attempts, 2u); + EXPECT_EQ(listed->reason, SharedData::UploadFailureReason::ServerRefused); + EXPECT_EQ(listed->failedAt, 20u); + + const auto third = failures_.record(failureAt(watch_, "/watched/a.txt")); + EXPECT_EQ(third.failureId, first.failureId); + EXPECT_EQ(third.attempts, 3u); + EXPECT_EQ(failures_.size(), 1u); + EXPECT_EQ(failures_.countOf(watch_), 1u); + } + + TEST_F(FailureListTest, AFailureWithoutDetailDropsTheDetailOfTheOneBefore) + { + failures_.record(failureAt(watch_, "/watched/a.txt", SharedData::UploadFailureReason::Unknown, 10u, "old")); + const auto again = failures_.record(failureAt(watch_, "/watched/a.txt")); + + EXPECT_FALSE(again.detail.has_value()); + EXPECT_FALSE(failures_.find(again.failureId)->detail.has_value()); + } + + TEST_F(FailureListTest, TheSamePathOfAnotherWatchIsAFailureOfItsOwn) + { + const auto mine = failures_.record(failureAt(watch_, "/watched/a.txt")); + const auto other = failures_.record(failureAt(otherWatch_, "/watched/a.txt")); + + EXPECT_NE(mine.failureId, other.failureId); + EXPECT_EQ(other.attempts, 1u); + EXPECT_EQ(failures_.countOf(watch_), 1u); + EXPECT_EQ(failures_.countOf(otherWatch_), 1u); + } + + TEST_F(FailureListTest, TheFailuresOfAWatchComeInTheOrderTheirPathsFirstFailed) + { + failures_.record(failureAt(watch_, "/watched/c.txt")); + failures_.record(failureAt(otherWatch_, "/other/x.txt")); + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/b.txt")); + failures_.record(failureAt(watch_, "/watched/c.txt")); + + EXPECT_EQ( + pathsOf(failures_.failuresOf(watch_)), + (std::vector{"/watched/c.txt", "/watched/a.txt", "/watched/b.txt"}) + ); + EXPECT_EQ(pathsOf(failures_.failuresOf(otherWatch_)), (std::vector{"/other/x.txt"})); + } + + TEST_F(FailureListTest, APathThatUploadedLosesItsFailureAndStartsAfreshWhenItFailsAgain) + { + const auto first = failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/b.txt")); + + const auto removed = failures_.removePath(watch_, localPathOf("/watched/a.txt")); + + ASSERT_TRUE(removed.has_value()); + EXPECT_EQ(removed->failureId, first.failureId); + EXPECT_EQ(removed->attempts, 2u); + EXPECT_EQ(failures_.find(first.failureId), nullptr); + EXPECT_EQ(failures_.find(watch_, localPathOf("/watched/a.txt")), nullptr); + EXPECT_EQ(failures_.countOf(watch_), 1u); + EXPECT_FALSE(failures_.removePath(watch_, localPathOf("/watched/a.txt")).has_value()); + EXPECT_FALSE(failures_.removePath(otherWatch_, localPathOf("/watched/b.txt")).has_value()); + + const auto again = failures_.record(failureAt(watch_, "/watched/a.txt")); + EXPECT_NE(again.failureId, first.failureId); + EXPECT_EQ(again.attempts, 1u); + EXPECT_EQ( + pathsOf(failures_.failuresOf(watch_)), (std::vector{"/watched/b.txt", "/watched/a.txt"}) + ); + } + + TEST_F(FailureListTest, DismissingAFailureByIdTakesOnlyItOff) + { + const auto dismissed = failures_.record(failureAt(watch_, "/watched/a.txt")); + const auto kept = failures_.record(failureAt(watch_, "/watched/b.txt")); + + const auto removed = failures_.remove(dismissed.failureId); + + ASSERT_TRUE(removed.has_value()); + EXPECT_EQ(removed->localPath.toUtf8(), "/watched/a.txt"); + EXPECT_EQ(failures_.find(dismissed.failureId), nullptr); + EXPECT_NE(failures_.find(kept.failureId), nullptr); + EXPECT_EQ(failures_.countOf(watch_), 1u); + EXPECT_FALSE(failures_.remove(dismissed.failureId).has_value()); + EXPECT_FALSE(failures_.remove(Ids::makeFailureId("unknown")).has_value()); + EXPECT_EQ(failures_.size(), 1u); + } + + TEST_F(FailureListTest, ADirectoryThatUploadedTakesItsFailuresAndThoseBelowOffButTheOnesItLeftOut) + { + failures_.record(failureAt(watch_, "/watched/dir/sub/b.txt")); + failures_.record(failureAt(watch_, "/watched/dir")); + failures_.record(failureAt(watch_, "/watched/dir-sibling.txt")); + failures_.record(failureAt(watch_, "/watched/dir/a.txt")); + failures_.record(failureAt(watch_, "/watched/other.txt")); + failures_.record(failureAt(otherWatch_, "/watched/dir/a.txt")); + + const auto removed = failures_.removeWithin( + watch_, localPathOf("/watched/dir"), std::set{localPathOf("/watched/dir/sub/b.txt")} + ); + + EXPECT_EQ(pathsOf(removed), (std::vector{"/watched/dir", "/watched/dir/a.txt"})); + EXPECT_EQ( + pathsOf(failures_.failuresOf(watch_)), + (std::vector{"/watched/dir/sub/b.txt", "/watched/dir-sibling.txt", "/watched/other.txt"}) + ); + EXPECT_EQ(failures_.countOf(otherWatch_), 1u); + } + + TEST_F(FailureListTest, RemovingWithinADirectoryWithoutExceptionsTakesEverythingBelowIt) + { + failures_.record(failureAt(watch_, "/watched/dir/a.txt")); + failures_.record(failureAt(watch_, "/watched/dir/sub/b.txt")); + failures_.record(failureAt(watch_, "/watched/other.txt")); + + const auto removed = failures_.removeWithin(watch_, localPathOf("/watched/dir")); + + EXPECT_EQ(pathsOf(removed), (std::vector{"/watched/dir/a.txt", "/watched/dir/sub/b.txt"})); + EXPECT_EQ(pathsOf(failures_.failuresOf(watch_)), (std::vector{"/watched/other.txt"})); + EXPECT_TRUE(failures_.removeWithin(otherWatch_, localPathOf("/watched")).empty()); + } + + TEST_F(FailureListTest, DroppingAWatchRemovesOnlyItsFailuresInTheOrderTheyFirstFailed) + { + const auto first = failures_.record(failureAt(watch_, "/watched/b.txt")); + const auto other = failures_.record(failureAt(otherWatch_, "/other/x.txt")); + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/b.txt")); + + const auto dropped = failures_.removeWatch(watch_); + + EXPECT_EQ(pathsOf(dropped), (std::vector{"/watched/b.txt", "/watched/a.txt"})); + ASSERT_FALSE(dropped.empty()); + EXPECT_EQ(dropped.front().failureId, first.failureId); + EXPECT_EQ(dropped.front().attempts, 2u); + EXPECT_EQ(failures_.find(first.failureId), nullptr); + EXPECT_EQ(failures_.countOf(watch_), 0u); + EXPECT_TRUE(failures_.failuresOf(watch_).empty()); + EXPECT_NE(failures_.find(other.failureId), nullptr); + EXPECT_EQ(failures_.size(), 1u); + EXPECT_TRUE(failures_.removeWatch(watch_).empty()); + } + + TEST_F(FailureListTest, RemovingAllHandsBackEveryFailureInTheOrderTheyFirstFailed) + { + failures_.record(failureAt(watch_, "/watched/b.txt")); + failures_.record(failureAt(otherWatch_, "/other/x.txt")); + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/b.txt")); + + const auto removed = failures_.removeAll(); + + EXPECT_EQ(pathsOf(removed), (std::vector{"/watched/b.txt", "/other/x.txt", "/watched/a.txt"})); + EXPECT_TRUE(failures_.empty()); + EXPECT_EQ(failures_.countOf(watch_), 0u); + EXPECT_EQ(failures_.countOf(otherWatch_), 0u); + EXPECT_TRUE(failures_.removeAll().empty()); + } + + TEST_F(FailureListTest, CountsAreKeptPerWatch) + { + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/b.txt")); + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(otherWatch_, "/other/x.txt")); + + EXPECT_EQ(failures_.countOf(watch_), 2u); + EXPECT_EQ(failures_.countOf(otherWatch_), 1u); + EXPECT_EQ(failures_.countOf(Ids::makeWatchId("unknown")), 0u); + EXPECT_EQ(failures_.size(), 3u); + } + + TEST_F(FailureListTest, NothingIsFoundForAnUnknownWatchPathOrId) + { + failures_.record(failureAt(watch_, "/watched/a.txt")); + + EXPECT_EQ(failures_.find(Ids::makeFailureId("unknown")), nullptr); + EXPECT_EQ(failures_.find(watch_, localPathOf("/watched/b.txt")), nullptr); + EXPECT_EQ(failures_.find(otherWatch_, localPathOf("/watched/a.txt")), nullptr); + EXPECT_TRUE(failures_.failuresOf(otherWatch_).empty()); + } +} From 6bbfc8863c9f1fa4027e9b8886de57930a0b6d29 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:02:00 +0200 Subject: [PATCH 017/114] Suffixed the zero counters of the conflict and failure lists as unsigned --- backend/include/backend/file_tracking/conflict_list.hpp | 4 ++-- backend/include/backend/file_tracking/failure_list.hpp | 4 ++-- backend/test/backend/test_conflict_list.hpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/include/backend/file_tracking/conflict_list.hpp b/backend/include/backend/file_tracking/conflict_list.hpp index 6797b642..f39d7384 100644 --- a/backend/include/backend/file_tracking/conflict_list.hpp +++ b/backend/include/backend/file_tracking/conflict_list.hpp @@ -175,7 +175,7 @@ namespace FileTracking struct Stored { Listed listed; - std::uint64_t arrival{0}; + std::uint64_t arrival{0u}; }; /** @@ -206,6 +206,6 @@ namespace FileTracking std::map conflicts_{}; std::map watches_{}; - std::uint64_t nextArrival_{0}; + std::uint64_t nextArrival_{0u}; }; } diff --git a/backend/include/backend/file_tracking/failure_list.hpp b/backend/include/backend/file_tracking/failure_list.hpp index 8402e321..b5ecb53e 100644 --- a/backend/include/backend/file_tracking/failure_list.hpp +++ b/backend/include/backend/file_tracking/failure_list.hpp @@ -185,7 +185,7 @@ namespace FileTracking struct Stored { SharedData::UploadFailure failure; - std::uint64_t arrival{0}; + std::uint64_t arrival{0u}; }; /** @@ -216,6 +216,6 @@ namespace FileTracking std::map failures_{}; std::map watches_{}; - std::uint64_t nextArrival_{0}; + std::uint64_t nextArrival_{0u}; }; } diff --git a/backend/test/backend/test_conflict_list.hpp b/backend/test/backend/test_conflict_list.hpp index e3aba3b9..8d9f3257 100644 --- a/backend/test/backend/test_conflict_list.hpp +++ b/backend/test/backend/test_conflict_list.hpp @@ -226,8 +226,8 @@ namespace Test::ConflictListCases EXPECT_EQ(labelsOf(dropped), (std::vector{"first", "second"})); ASSERT_EQ(dropped.size(), 2u); - EXPECT_EQ(dropped[0].conflict.conflictId, first.conflict.conflictId); - EXPECT_EQ(dropped[1].conflict.conflictId, second.conflict.conflictId); + EXPECT_EQ(dropped.front().conflict.conflictId, first.conflict.conflictId); + EXPECT_EQ(dropped.back().conflict.conflictId, second.conflict.conflictId); EXPECT_EQ(conflicts_.find(first.conflict.conflictId), nullptr); EXPECT_EQ(conflicts_.find(watch_, localPathOf("/watched/a.txt")), nullptr); EXPECT_EQ(conflicts_.countOf(watch_), 0u); From 17aa4b9b53ae84de74dba87e429b7cd58e7a2d59 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:02:00 +0200 Subject: [PATCH 018/114] Measured the progress of a watch per burst, with bytes and files in flight --- .../file_tracking/watch_progress_meter.hpp | 126 ++++ .../file_tracking/watch_progress_meter.cpp | 180 ++++++ .../backend/test_watch_progress_meter.hpp | 544 ++++++++++++++++++ 3 files changed, 850 insertions(+) diff --git a/backend/include/backend/file_tracking/watch_progress_meter.hpp b/backend/include/backend/file_tracking/watch_progress_meter.hpp index 6f70f09b..990bfcd4 100644 --- a/backend/include/backend/file_tracking/watch_progress_meter.hpp +++ b/backend/include/backend/file_tracking/watch_progress_meter.hpp @@ -1 +1,127 @@ #pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief Measures how far one watch is with its changes, see SharedData::WatchProgress. The tracker tells it how + * many changes are on their way and how its uploads go, by the local path each upload started with. A + * burst lasts until nothing is on its way, finishBurst() then keeps it as the last sync. + * + * Files that an upload still on its way finished count as done, the others it started as running, every other + * pending change as waiting. A completed upload counts its bytes as done; one that did not complete keeps the + * bytes it sent and its other bytes leave the total, so the bar still reaches its end. + */ + class WatchProgressMeter + { + public: + /** + * @brief How an upload ended, see uploadEnded(). + */ + enum class UploadResult + { + Completed, + /** @brief Failed, cancelled or never queued. */ + NotCompleted, + }; + + explicit WatchProgressMeter(Ids::WatchId watchId); + + Ids::WatchId const& watchId() const; + + /** + * @brief The changes on their way: waiting for the check, an answer or the queue, or running. The files that + * uploads on their way finished are among them until those uploads ended. + */ + void setChangesPending(std::uint64_t count); + + /** + * @brief The pending changes that wait for the user's answer to a conflict. + */ + void setWaitingForAnswer(std::uint64_t count); + + /** + * @brief @p count changes of the current burst ended, however they went. + */ + void changesEnded(std::uint64_t count); + + /** + * @brief The upload of @p path was handed to the queue, @p sizeBytes is its size when known. An upload of + * @p path still on its way counts as ended without completing. + */ + void uploadQueued(Paths::LocalPath const& path, std::optional sizeBytes = std::nullopt); + + /** + * @brief The upload of @p path is as far as @p progress, it started on the server. Nothing for an upload + * that is not on its way. + */ + void uploadProgressed(Paths::LocalPath const& path, UploadProgress const& progress); + + /** + * @brief The upload of @p path ended. End it together with changesEnded() and setChangesPending() for its + * changes, the files it finished count as pending again in between. + */ + void uploadEnded(Paths::LocalPath const& path, UploadResult result); + + /** + * @brief Forgets what is on its way, for a watch that stops: the uploads count as ended without completing, + * no change is pending or waits for an answer. + */ + void dropPending(); + + /** + * @brief Whether nothing is on its way: no pending change, none waiting for an answer, no upload. + */ + bool isIdle() const; + + /** + * @brief Keeps the current burst as the last sync, ended at @p endedAt (seconds since the Unix epoch), and + * starts the next one. Nothing happens while something is on its way, nor for a burst in which nothing + * ended and nothing was sent. + * @return Whether it kept the burst. + */ + bool finishBurst(std::uint64_t endedAt); + + SharedData::WatchProgress snapshot() const; + + /** + * @brief Whether the snapshot changed since takeDirty() was called last. + */ + bool isDirty() const; + + /** + * @brief Whether the snapshot changed since this was called last, see isDirty(). + */ + bool takeDirty(); + + private: + struct ActiveUpload + { + std::optional sizeBytes{}; + /** @brief The last progress, nothing before the upload started on the server. */ + std::optional progress{}; + }; + + void endUpload(ActiveUpload const& upload, UploadResult result); + + private: + Ids::WatchId watchId_; + std::uint64_t changesPending_{0u}; + std::uint64_t waitingForAnswer_{0u}; + /** @brief The changes of the current burst that ended. */ + std::uint64_t burstChangesDone_{0u}; + /** @brief The bytes the ended uploads of the current burst count as, both done and total. */ + std::uint64_t burstBytes_{0u}; + std::map uploads_{}; + std::optional lastSync_{}; + bool dirty_{false}; + }; +} diff --git a/backend/source/backend/file_tracking/watch_progress_meter.cpp b/backend/source/backend/file_tracking/watch_progress_meter.cpp index d0decae8..d03b2ee8 100644 --- a/backend/source/backend/file_tracking/watch_progress_meter.cpp +++ b/backend/source/backend/file_tracking/watch_progress_meter.cpp @@ -1 +1,181 @@ #include + +#include +#include +#include +#include + +namespace FileTracking +{ + namespace + { + std::uint64_t bytesSentOf(std::optional const& progress) + { + if (!progress) + return 0u; + return progress->transferred ? std::max(progress->bytesSent, progress->bytesTotal) : progress->bytesSent; + } + + std::uint64_t + bytesTotalOf(std::optional const& progress, std::optional const& sizeBytes) + { + const auto told = progress && progress->bytesTotal > 0u ? progress->bytesTotal : sizeBytes.value_or(0u); + return std::max(told, bytesSentOf(progress)); + } + + /** + * @brief The files an upload finished, all of them once every byte went. + */ + std::uint64_t filesDoneOf(std::optional const& progress) + { + if (!progress) + return 0u; + if (progress->transferred) + return std::max({progress->filesDone, progress->fileCount, std::uint64_t{1u}}); + return progress->filesDone; + } + + /** + * @brief The files an upload started and did not finish, at least the one it is busy with. + */ + std::uint64_t filesRunningOf(std::optional const& progress) + { + if (!progress || progress->transferred) + return 0u; + return std::max(static_cast(progress->inFlight.size()), std::uint64_t{1u}); + } + } + + WatchProgressMeter::WatchProgressMeter(Ids::WatchId watchId) + : watchId_{std::move(watchId)} + {} + + Ids::WatchId const& WatchProgressMeter::watchId() const + { + return watchId_; + } + + void WatchProgressMeter::setChangesPending(std::uint64_t count) + { + if (std::exchange(changesPending_, count) != count) + dirty_ = true; + } + + void WatchProgressMeter::setWaitingForAnswer(std::uint64_t count) + { + if (std::exchange(waitingForAnswer_, count) != count) + dirty_ = true; + } + + void WatchProgressMeter::changesEnded(std::uint64_t count) + { + if (count == 0u) + return; + burstChangesDone_ += count; + dirty_ = true; + } + + void WatchProgressMeter::uploadQueued(Paths::LocalPath const& path, std::optional sizeBytes) + { + if (const auto previous = uploads_.find(path); previous != uploads_.end()) + endUpload(previous->second, UploadResult::NotCompleted); + uploads_.insert_or_assign(path, ActiveUpload{.sizeBytes = sizeBytes, .progress = std::nullopt}); + dirty_ = true; + } + + void WatchProgressMeter::uploadProgressed(Paths::LocalPath const& path, UploadProgress const& progress) + { + const auto upload = uploads_.find(path); + if (upload == uploads_.end()) + return; + upload->second.progress = progress; + dirty_ = true; + } + + void WatchProgressMeter::uploadEnded(Paths::LocalPath const& path, UploadResult result) + { + const auto node = uploads_.extract(path); + if (node.empty()) + return; + endUpload(node.mapped(), result); + dirty_ = true; + } + + void WatchProgressMeter::endUpload(ActiveUpload const& upload, UploadResult result) + { + burstBytes_ += result == UploadResult::Completed ? bytesTotalOf(upload.progress, upload.sizeBytes) + : bytesSentOf(upload.progress); + } + + void WatchProgressMeter::dropPending() + { + if (isIdle()) + return; + for (auto const& upload : std::views::values(uploads_)) + endUpload(upload, UploadResult::NotCompleted); + uploads_.clear(); + changesPending_ = 0u; + waitingForAnswer_ = 0u; + dirty_ = true; + } + + bool WatchProgressMeter::isIdle() const + { + return changesPending_ == 0u && waitingForAnswer_ == 0u && uploads_.empty(); + } + + bool WatchProgressMeter::finishBurst(std::uint64_t endedAt) + { + if (!isIdle() || (burstChangesDone_ == 0u && burstBytes_ == 0u)) + return false; + lastSync_ = SharedData::WatchSync{ + .endedAt = endedAt, + .changes = std::exchange(burstChangesDone_, 0u), + .bytes = std::exchange(burstBytes_, 0u), + }; + dirty_ = true; + return true; + } + + SharedData::WatchProgress WatchProgressMeter::snapshot() const + { + std::uint64_t filesDone = 0u; + std::uint64_t filesRunning = 0u; + std::uint64_t bytesDone = burstBytes_; + std::uint64_t bytesTotal = burstBytes_; + std::vector inFlight; + for (auto const& upload : std::views::values(uploads_)) + { + filesDone += filesDoneOf(upload.progress); + filesRunning += filesRunningOf(upload.progress); + bytesDone += bytesSentOf(upload.progress); + bytesTotal += bytesTotalOf(upload.progress, upload.sizeBytes); + if (upload.progress && !upload.progress->transferred) + inFlight.insert(inFlight.end(), upload.progress->inFlight.begin(), upload.progress->inFlight.end()); + } + const auto finished = std::min(filesDone, changesPending_); + const auto pending = changesPending_ - finished; + const auto waitingForAnswer = std::min(waitingForAnswer_, pending); + return SharedData::WatchProgress{ + .watchId = watchId_, + .changesPending = pending, + .changesDone = burstChangesDone_ + finished, + .changesWaiting = std::max(pending - std::min(filesRunning, pending), waitingForAnswer), + .waitingForAnswer = waitingForAnswer, + .bytesDone = bytesDone, + .bytesTotal = bytesTotal, + .inFlight = std::move(inFlight), + .lastSync = lastSync_, + }; + } + + bool WatchProgressMeter::isDirty() const + { + return dirty_; + } + + bool WatchProgressMeter::takeDirty() + { + return std::exchange(dirty_, false); + } +} diff --git a/backend/test/backend/test_watch_progress_meter.hpp b/backend/test/backend/test_watch_progress_meter.hpp index 6f70f09b..f7de94c9 100644 --- a/backend/test/backend/test_watch_progress_meter.hpp +++ b/backend/test/backend/test_watch_progress_meter.hpp @@ -1 +1,545 @@ #pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace Test::WatchProgressMeterCases +{ + using UploadResult = FileTracking::WatchProgressMeter::UploadResult; + + inline Paths::LocalPath localPathOf(std::string_view path) + { + return Paths::LocalPath::fromUtf8(path, Paths::LocalPlatform::Posix).value(); + } + + inline SharedData::WatchFileProgress fileAt(std::string_view path, std::uint64_t bytes, std::uint64_t totalBytes) + { + return SharedData::WatchFileProgress{.path = localPathOf(path), .bytes = bytes, .totalBytes = totalBytes}; + } + + /** + * @brief The progress of an upload of a single file that is on its way. + */ + inline FileTracking::UploadProgress + fileProgress(std::string_view path, std::uint64_t bytesSent, std::uint64_t bytesTotal, bool transferred = false) + { + return FileTracking::UploadProgress{ + .bytesSent = bytesSent, + .bytesTotal = bytesTotal, + .inFlight = {fileAt(path, bytesSent, bytesTotal)}, + .filesDone = 0u, + .fileCount = 1u, + .transferred = transferred, + }; + } + + inline std::vector pathsOf(std::vector const& inFlight) + { + std::vector paths; + for (auto const& file : inFlight) + paths.push_back(file.path.toUtf8()); + return paths; + } + + class WatchProgressMeterTest : public ::testing::Test + { + protected: + Ids::WatchId watch_{Ids::makeWatchId("watch-one")}; + FileTracking::WatchProgressMeter meter_{watch_}; + + /** + * @brief Ends the upload of @p path that stood for @p changes changes, @p pendingAfter are on their way then, + * like the tracker does it. + */ + void endUpload(std::string_view path, UploadResult result, std::uint64_t changes, std::uint64_t pendingAfter) + { + meter_.uploadEnded(localPathOf(path), result); + meter_.changesEnded(changes); + meter_.setChangesPending(pendingAfter); + } + }; + + TEST_F(WatchProgressMeterTest, ANewMeterShowsNothingOnItsWay) + { + const auto progress = meter_.snapshot(); + + EXPECT_EQ(meter_.watchId(), watch_); + EXPECT_EQ(progress.watchId, watch_); + EXPECT_EQ(progress.changesPending, 0u); + EXPECT_EQ(progress.changesDone, 0u); + EXPECT_EQ(progress.changesWaiting, 0u); + EXPECT_EQ(progress.waitingForAnswer, 0u); + EXPECT_EQ(progress.bytesDone, 0u); + EXPECT_EQ(progress.bytesTotal, 0u); + EXPECT_TRUE(progress.inFlight.empty()); + EXPECT_FALSE(progress.lastSync.has_value()); + EXPECT_TRUE(meter_.isIdle()); + EXPECT_FALSE(meter_.isDirty()); + } + + TEST_F(WatchProgressMeterTest, AChangeWaitsUntilItsUploadStartsOnTheServer) + { + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + + const auto queued = meter_.snapshot(); + EXPECT_EQ(queued.changesPending, 1u); + EXPECT_EQ(queued.changesWaiting, 1u); + EXPECT_EQ(queued.changesDone, 0u); + EXPECT_EQ(queued.bytesDone, 0u); + EXPECT_EQ(queued.bytesTotal, 100u); + EXPECT_TRUE(queued.inFlight.empty()); + EXPECT_FALSE(meter_.isIdle()); + + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 40u, 100u)); + + const auto running = meter_.snapshot(); + EXPECT_EQ(running.changesPending, 1u); + EXPECT_EQ(running.changesWaiting, 0u); + EXPECT_EQ(running.bytesDone, 40u); + EXPECT_EQ(running.bytesTotal, 100u); + ASSERT_EQ(running.inFlight.size(), 1u); + EXPECT_EQ(running.inFlight.front().path.toUtf8(), "/watched/a.txt"); + EXPECT_EQ(running.inFlight.front().bytes, 40u); + EXPECT_EQ(running.inFlight.front().totalBytes, 100u); + } + + TEST_F(WatchProgressMeterTest, ACompletedUploadCountsAllItsBytesAsDone) + { + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 40u, 100u)); + + endUpload("/watched/a.txt", UploadResult::Completed, 1u, 0u); + + const auto progress = meter_.snapshot(); + EXPECT_EQ(progress.changesPending, 0u); + EXPECT_EQ(progress.changesDone, 1u); + EXPECT_EQ(progress.changesWaiting, 0u); + EXPECT_EQ(progress.bytesDone, 100u); + EXPECT_EQ(progress.bytesTotal, 100u); + EXPECT_TRUE(progress.inFlight.empty()); + EXPECT_TRUE(meter_.isIdle()); + } + + TEST_F(WatchProgressMeterTest, ACompletedUploadWithoutProgressCountsTheSizeKnownBefore) + { + meter_.setChangesPending(2u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 70u); + meter_.uploadQueued(localPathOf("/watched/b.txt")); + + endUpload("/watched/a.txt", UploadResult::Completed, 1u, 1u); + endUpload("/watched/b.txt", UploadResult::Completed, 1u, 0u); + + const auto progress = meter_.snapshot(); + EXPECT_EQ(progress.changesDone, 2u); + EXPECT_EQ(progress.bytesDone, 70u); + EXPECT_EQ(progress.bytesTotal, 70u); + } + + TEST_F(WatchProgressMeterTest, TheBytesAFailedUploadDidNotSendLeaveTheTotal) + { + meter_.setChangesPending(2u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadQueued(localPathOf("/watched/b.txt"), 300u); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 50u, 100u)); + meter_.uploadProgressed(localPathOf("/watched/b.txt"), fileProgress("/watched/b.txt", 100u, 300u)); + ASSERT_EQ(meter_.snapshot().bytesDone, 150u); + ASSERT_EQ(meter_.snapshot().bytesTotal, 400u); + + endUpload("/watched/b.txt", UploadResult::NotCompleted, 1u, 1u); + + const auto failed = meter_.snapshot(); + EXPECT_EQ(failed.bytesDone, 150u); + EXPECT_EQ(failed.bytesTotal, 200u); + EXPECT_EQ(failed.changesDone, 1u); + EXPECT_EQ(failed.changesPending, 1u); + EXPECT_EQ(pathsOf(failed.inFlight), (std::vector{"/watched/a.txt"})); + + endUpload("/watched/a.txt", UploadResult::Completed, 1u, 0u); + + const auto ended = meter_.snapshot(); + EXPECT_EQ(ended.bytesDone, 200u); + EXPECT_EQ(ended.bytesTotal, 200u); + EXPECT_EQ(ended.changesDone, 2u); + EXPECT_EQ(ended.changesPending, 0u); + } + + TEST_F(WatchProgressMeterTest, AFailedUploadThatSentNothingLeavesTheTotalWhole) + { + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 500u); + ASSERT_EQ(meter_.snapshot().bytesTotal, 500u); + + endUpload("/watched/a.txt", UploadResult::NotCompleted, 1u, 0u); + + const auto progress = meter_.snapshot(); + EXPECT_EQ(progress.bytesDone, 0u); + EXPECT_EQ(progress.bytesTotal, 0u); + EXPECT_EQ(progress.changesDone, 1u); + } + + TEST_F(WatchProgressMeterTest, TheTotalTheQueueTellsReplacesTheSizeKnownBefore) + { + meter_.setChangesPending(2u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadQueued(localPathOf("/watched/b.txt")); + ASSERT_EQ(meter_.snapshot().bytesTotal, 100u); + + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 0u, 150u)); + meter_.uploadProgressed(localPathOf("/watched/b.txt"), fileProgress("/watched/b.txt", 5u, 20u)); + + EXPECT_EQ(meter_.snapshot().bytesTotal, 170u); + EXPECT_EQ(meter_.snapshot().bytesDone, 5u); + } + + TEST_F(WatchProgressMeterTest, OverlappingUploadsAddUpAndEndOneByOne) + { + meter_.setChangesPending(5u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadQueued(localPathOf("/watched/b.txt"), 200u); + meter_.uploadQueued(localPathOf("/watched/c")); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 30u, 100u)); + meter_.uploadProgressed( + localPathOf("/watched/c"), + FileTracking::UploadProgress{ + .bytesSent = 10u, + .bytesTotal = 50u, + .inFlight = {fileAt("/watched/c/x.txt", 5u, 20u), fileAt("/watched/c/y.txt", 5u, 30u)}, + .filesDone = 0u, + .fileCount = 2u, + .transferred = false, + } + ); + + const auto overlapping = meter_.snapshot(); + EXPECT_EQ(overlapping.bytesDone, 40u); + EXPECT_EQ(overlapping.bytesTotal, 350u); + EXPECT_EQ( + pathsOf(overlapping.inFlight), + (std::vector{"/watched/a.txt", "/watched/c/x.txt", "/watched/c/y.txt"}) + ); + EXPECT_EQ(overlapping.changesPending, 5u); + EXPECT_EQ(overlapping.changesWaiting, 2u); + EXPECT_EQ(overlapping.changesDone, 0u); + + endUpload("/watched/a.txt", UploadResult::Completed, 1u, 4u); + + const auto oneEnded = meter_.snapshot(); + EXPECT_EQ(oneEnded.bytesDone, 110u); + EXPECT_EQ(oneEnded.bytesTotal, 350u); + EXPECT_EQ(pathsOf(oneEnded.inFlight), (std::vector{"/watched/c/x.txt", "/watched/c/y.txt"})); + EXPECT_EQ(oneEnded.changesPending, 4u); + EXPECT_EQ(oneEnded.changesWaiting, 2u); + EXPECT_EQ(oneEnded.changesDone, 1u); + } + + TEST_F(WatchProgressMeterTest, TheFilesADirectoryUploadFinishedCountAsDoneBeforeItEnds) + { + meter_.setChangesPending(48u); + meter_.uploadQueued(localPathOf("/watched/dir")); + meter_.uploadProgressed( + localPathOf("/watched/dir"), + FileTracking::UploadProgress{ + .bytesSent = 1200u, + .bytesTotal = 4800u, + .inFlight = {fileAt("/watched/dir/13.txt", 50u, 100u), fileAt("/watched/dir/14.txt", 50u, 100u)}, + .filesDone = 12u, + .fileCount = 48u, + .transferred = false, + } + ); + + const auto running = meter_.snapshot(); + EXPECT_EQ(running.changesDone, 12u); + EXPECT_EQ(running.changesPending, 36u); + EXPECT_EQ(running.changesWaiting, 34u); + EXPECT_EQ(running.bytesDone, 1200u); + EXPECT_EQ(running.bytesTotal, 4800u); + EXPECT_EQ(running.inFlight.size(), 2u); + + endUpload("/watched/dir", UploadResult::Completed, 48u, 0u); + + const auto ended = meter_.snapshot(); + EXPECT_EQ(ended.changesDone, 48u); + EXPECT_EQ(ended.changesPending, 0u); + EXPECT_EQ(ended.changesWaiting, 0u); + EXPECT_EQ(ended.bytesDone, 4800u); + EXPECT_EQ(ended.bytesTotal, 4800u); + EXPECT_TRUE(ended.inFlight.empty()); + } + + TEST_F(WatchProgressMeterTest, AnUploadWhoseBytesAllWentIsDoneButStillOnItsWay) + { + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 90u, 100u, true)); + + const auto progress = meter_.snapshot(); + EXPECT_TRUE(progress.inFlight.empty()); + EXPECT_EQ(progress.bytesDone, 100u); + EXPECT_EQ(progress.bytesTotal, 100u); + EXPECT_EQ(progress.changesDone, 1u); + EXPECT_EQ(progress.changesPending, 0u); + EXPECT_EQ(progress.changesWaiting, 0u); + EXPECT_FALSE(meter_.isIdle()); + EXPECT_FALSE(meter_.finishBurst(10u)); + } + + TEST_F(WatchProgressMeterTest, ChangesWaitingForAnAnswerArePendingAndWaiting) + { + meter_.setChangesPending(3u); + meter_.setWaitingForAnswer(1u); + meter_.uploadQueued(localPathOf("/watched/b.txt"), 10u); + meter_.uploadProgressed(localPathOf("/watched/b.txt"), fileProgress("/watched/b.txt", 1u, 10u)); + + const auto progress = meter_.snapshot(); + EXPECT_EQ(progress.changesPending, 3u); + EXPECT_EQ(progress.waitingForAnswer, 1u); + EXPECT_EQ(progress.changesWaiting, 2u); + EXPECT_FALSE(meter_.isIdle()); + } + + TEST_F(WatchProgressMeterTest, WaitingCountsStayWithinThePendingOnes) + { + meter_.setChangesPending(2u); + meter_.setWaitingForAnswer(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt")); + meter_.uploadQueued(localPathOf("/watched/b.txt")); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 1u, 10u)); + meter_.uploadProgressed(localPathOf("/watched/b.txt"), fileProgress("/watched/b.txt", 1u, 10u)); + + const auto running = meter_.snapshot(); + EXPECT_EQ(running.changesWaiting, 1u); + EXPECT_EQ(running.waitingForAnswer, 1u); + + meter_.setWaitingForAnswer(5u); + + const auto clamped = meter_.snapshot(); + EXPECT_EQ(clamped.waitingForAnswer, 2u); + EXPECT_EQ(clamped.changesWaiting, 2u); + EXPECT_EQ(clamped.changesPending, 2u); + } + + TEST_F(WatchProgressMeterTest, FinishingABurstKeepsItAsTheLastSyncAndStartsTheNext) + { + meter_.setChangesPending(2u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadQueued(localPathOf("/watched/b.txt"), 50u); + endUpload("/watched/a.txt", UploadResult::Completed, 1u, 1u); + endUpload("/watched/b.txt", UploadResult::Completed, 1u, 0u); + + EXPECT_TRUE(meter_.finishBurst(1000u)); + + const auto idle = meter_.snapshot(); + EXPECT_EQ(idle.changesDone, 0u); + EXPECT_EQ(idle.changesPending, 0u); + EXPECT_EQ(idle.bytesDone, 0u); + EXPECT_EQ(idle.bytesTotal, 0u); + ASSERT_TRUE(idle.lastSync.has_value()); + EXPECT_EQ(idle.lastSync->endedAt, 1000u); + EXPECT_EQ(idle.lastSync->changes, 2u); + EXPECT_EQ(idle.lastSync->bytes, 150u); + + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/c.txt"), 10u); + + const auto next = meter_.snapshot(); + EXPECT_EQ(next.bytesTotal, 10u); + EXPECT_EQ(next.changesDone, 0u); + ASSERT_TRUE(next.lastSync.has_value()); + EXPECT_EQ(next.lastSync->endedAt, 1000u); + + endUpload("/watched/c.txt", UploadResult::Completed, 1u, 0u); + EXPECT_TRUE(meter_.finishBurst(2000u)); + + const auto second = meter_.snapshot().lastSync; + ASSERT_TRUE(second.has_value()); + EXPECT_EQ(second->endedAt, 2000u); + EXPECT_EQ(second->changes, 1u); + EXPECT_EQ(second->bytes, 10u); + } + + TEST_F(WatchProgressMeterTest, ABurstDoesNotFinishWhileSomethingIsOnItsWay) + { + meter_.changesEnded(1u); + + meter_.setChangesPending(1u); + EXPECT_FALSE(meter_.isIdle()); + EXPECT_FALSE(meter_.finishBurst(10u)); + + meter_.setChangesPending(0u); + meter_.setWaitingForAnswer(1u); + EXPECT_FALSE(meter_.isIdle()); + EXPECT_FALSE(meter_.finishBurst(10u)); + + meter_.setWaitingForAnswer(0u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 10u); + EXPECT_FALSE(meter_.isIdle()); + EXPECT_FALSE(meter_.finishBurst(10u)); + + EXPECT_FALSE(meter_.snapshot().lastSync.has_value()); + EXPECT_EQ(meter_.snapshot().changesDone, 1u); + } + + TEST_F(WatchProgressMeterTest, ABurstInWhichNothingEndedLeavesTheLastSync) + { + EXPECT_FALSE(meter_.finishBurst(10u)); + EXPECT_FALSE(meter_.snapshot().lastSync.has_value()); + + meter_.changesEnded(3u); + ASSERT_TRUE(meter_.finishBurst(20u)); + EXPECT_FALSE(meter_.finishBurst(30u)); + + const auto lastSync = meter_.snapshot().lastSync; + ASSERT_TRUE(lastSync.has_value()); + EXPECT_EQ(lastSync->endedAt, 20u); + EXPECT_EQ(lastSync->changes, 3u); + EXPECT_EQ(lastSync->bytes, 0u); + } + + TEST_F(WatchProgressMeterTest, ABurstOfAFailedUploadKeepsTheBytesItSent) + { + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 30u, 100u)); + endUpload("/watched/a.txt", UploadResult::NotCompleted, 1u, 0u); + + ASSERT_TRUE(meter_.finishBurst(5u)); + + const auto lastSync = meter_.snapshot().lastSync; + ASSERT_TRUE(lastSync.has_value()); + EXPECT_EQ(lastSync->changes, 1u); + EXPECT_EQ(lastSync->bytes, 30u); + } + + TEST_F(WatchProgressMeterTest, ProgressOfAnUploadThatIsNotOnItsWayIsIgnored) + { + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 30u, 100u)); + EXPECT_EQ(meter_.snapshot().bytesTotal, 0u); + EXPECT_TRUE(meter_.snapshot().inFlight.empty()); + EXPECT_FALSE(meter_.isDirty()); + + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadEnded(localPathOf("/watched/a.txt"), UploadResult::Completed); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 30u, 100u)); + + const auto progress = meter_.snapshot(); + EXPECT_EQ(progress.bytesDone, 100u); + EXPECT_EQ(progress.bytesTotal, 100u); + EXPECT_TRUE(progress.inFlight.empty()); + EXPECT_TRUE(meter_.isIdle()); + } + + TEST_F(WatchProgressMeterTest, QueueingAPathAgainEndsItsUploadOnItsWayWithoutCompleting) + { + meter_.setChangesPending(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 40u, 100u)); + + meter_.uploadQueued(localPathOf("/watched/a.txt"), 200u); + + const auto progress = meter_.snapshot(); + EXPECT_EQ(progress.bytesDone, 40u); + EXPECT_EQ(progress.bytesTotal, 240u); + EXPECT_TRUE(progress.inFlight.empty()); + EXPECT_EQ(progress.changesWaiting, 1u); + } + + TEST_F(WatchProgressMeterTest, DroppingWhatIsOnItsWayLetsTheBurstFinish) + { + meter_.changesEnded(1u); + meter_.setChangesPending(3u); + meter_.setWaitingForAnswer(1u); + meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 60u, 100u)); + meter_.takeDirty(); + + meter_.dropPending(); + + EXPECT_TRUE(meter_.isDirty()); + EXPECT_TRUE(meter_.isIdle()); + const auto dropped = meter_.snapshot(); + EXPECT_EQ(dropped.changesPending, 0u); + EXPECT_EQ(dropped.changesWaiting, 0u); + EXPECT_EQ(dropped.waitingForAnswer, 0u); + EXPECT_EQ(dropped.changesDone, 1u); + EXPECT_EQ(dropped.bytesDone, 60u); + EXPECT_EQ(dropped.bytesTotal, 60u); + EXPECT_TRUE(dropped.inFlight.empty()); + + ASSERT_TRUE(meter_.finishBurst(9u)); + const auto lastSync = meter_.snapshot().lastSync; + ASSERT_TRUE(lastSync.has_value()); + EXPECT_EQ(lastSync->endedAt, 9u); + EXPECT_EQ(lastSync->changes, 1u); + EXPECT_EQ(lastSync->bytes, 60u); + } + + TEST_F(WatchProgressMeterTest, DroppingNothingLeavesTheMeterClean) + { + meter_.dropPending(); + + EXPECT_FALSE(meter_.isDirty()); + EXPECT_TRUE(meter_.isIdle()); + } + + TEST_F(WatchProgressMeterTest, TheDirtyFlagTellsOfChangesUntilItIsTaken) + { + EXPECT_FALSE(meter_.takeDirty()); + + meter_.setChangesPending(0u); + meter_.setWaitingForAnswer(0u); + meter_.changesEnded(0u); + EXPECT_FALSE(meter_.isDirty()); + + meter_.setChangesPending(1u); + EXPECT_TRUE(meter_.isDirty()); + EXPECT_TRUE(meter_.isDirty()); + EXPECT_TRUE(meter_.takeDirty()); + EXPECT_FALSE(meter_.isDirty()); + EXPECT_FALSE(meter_.takeDirty()); + + meter_.setChangesPending(1u); + EXPECT_FALSE(meter_.isDirty()); + + meter_.uploadQueued(localPathOf("/watched/a.txt"), 10u); + EXPECT_TRUE(meter_.takeDirty()); + + meter_.uploadProgressed(localPathOf("/watched/b.txt"), fileProgress("/watched/b.txt", 1u, 10u)); + meter_.uploadEnded(localPathOf("/watched/b.txt"), UploadResult::Completed); + EXPECT_FALSE(meter_.isDirty()); + + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 1u, 10u)); + EXPECT_TRUE(meter_.takeDirty()); + + meter_.setWaitingForAnswer(1u); + EXPECT_TRUE(meter_.takeDirty()); + + EXPECT_FALSE(meter_.finishBurst(5u)); + EXPECT_FALSE(meter_.isDirty()); + + meter_.uploadEnded(localPathOf("/watched/a.txt"), UploadResult::Completed); + EXPECT_TRUE(meter_.takeDirty()); + + meter_.changesEnded(1u); + EXPECT_TRUE(meter_.takeDirty()); + + meter_.setChangesPending(0u); + meter_.setWaitingForAnswer(0u); + meter_.takeDirty(); + EXPECT_TRUE(meter_.finishBurst(5u)); + EXPECT_TRUE(meter_.takeDirty()); + } +} From be81b5f7023ea7ece9e56f6cbf498db84f9b3da9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:54:30 +0200 Subject: [PATCH 019/114] Let the shared input file stream tell why its last open or read failed --- .../utility/shared_input_file_stream.hpp | 20 +++ .../utility/shared_input_file_stream.cpp | 93 ++++++++-- .../utility/test_shared_input_file_stream.hpp | 166 ++++++++++++++++++ 3 files changed, 267 insertions(+), 12 deletions(-) diff --git a/utility/include/utility/shared_input_file_stream.hpp b/utility/include/utility/shared_input_file_stream.hpp index 60a3239e..096835f6 100644 --- a/utility/include/utility/shared_input_file_stream.hpp +++ b/utility/include/utility/shared_input_file_stream.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace Utility { @@ -31,6 +32,13 @@ namespace Utility bool isOpen() const; void close(); + /** + * @brief Why the last open or read failed, kept until the next open, empty when none failed. On Windows the + * system's own code comes in std::system_category(), an error of the C runtime as its errno value in + * std::generic_category(). Elsewhere it is always errno in std::generic_category(). + */ + std::error_code lastError() const; + protected: int_type underflow() override; std::streamsize xsgetn(char_type* destination, std::streamsize count) override; @@ -43,9 +51,16 @@ namespace Utility void operator()(std::FILE* file) const; }; + /** + * @brief Reads up to @p count bytes into @p destination, noting why when that fails. + * @return How many bytes were read. + */ + std::size_t readInto(char* destination, std::size_t count); + private: std::unique_ptr file_{}; std::array buffer_{}; + std::error_code lastError_{}; }; /** @@ -69,6 +84,11 @@ namespace Utility bool is_open() const; void close(); + /** + * @brief Why the last open or read failed, see SharedInputFileBuffer::lastError. + */ + std::error_code lastError() const; + private: SharedInputFileBuffer buffer_; }; diff --git a/utility/source/utility/shared_input_file_stream.cpp b/utility/source/utility/shared_input_file_stream.cpp index ff134561..e504a125 100644 --- a/utility/source/utility/shared_input_file_stream.cpp +++ b/utility/source/utility/shared_input_file_stream.cpp @@ -9,8 +9,11 @@ #endif #include +#include #include +#include #include +#include #include #include #include @@ -61,7 +64,29 @@ namespace Utility int descriptor_; }; - std::FILE* openShared(std::filesystem::path const& path) + /** + * @brief Forgets the errors noted so far, so the next failing call of the C runtime leaves only its own. + */ + void clearSystemError() + { + errno = 0; + _doserrno = 0; + } + + /** + * @brief The error the last failing call of the C runtime noted: the Windows code it got, if any, otherwise + * its errno value, and an I/O error when it noted neither. + */ + std::error_code lastSystemError() + { + if (_doserrno != 0) + return std::error_code{static_cast(_doserrno), std::system_category()}; + if (errno != 0) + return std::error_code{errno, std::generic_category()}; + return std::make_error_code(std::errc::io_error); + } + + std::expected openShared(std::filesystem::path const& path) { auto handle = std::unique_ptr, HandleCloser>{::CreateFileW( path.c_str(), @@ -74,19 +99,23 @@ namespace Utility )}; if (handle.get() == INVALID_HANDLE_VALUE) { + const std::error_code error{static_cast(::GetLastError()), std::system_category()}; std::ignore = handle.release(); - return nullptr; + return std::unexpected(error); } + clearSystemError(); OwnedDescriptor descriptor{ ::_open_osfhandle(reinterpret_cast(handle.get()), _O_RDONLY | _O_BINARY) }; if (descriptor.get() == -1) - return nullptr; + return std::unexpected(lastSystemError()); // The descriptor owns the handle now. std::ignore = handle.release(); + clearSystemError(); auto* file = ::_fdopen(descriptor.get(), "rb"); - if (file != nullptr) - std::ignore = descriptor.release(); + if (file == nullptr) + return std::unexpected(lastSystemError()); + std::ignore = descriptor.release(); return file; } @@ -100,9 +129,28 @@ namespace Utility return ::_ftelli64(file); } #else - std::FILE* openShared(std::filesystem::path const& path) + void clearSystemError() + { + errno = 0; + } + + /** + * @brief The errno value the last failing call noted, an I/O error when it noted none. + */ + std::error_code lastSystemError() { - return std::fopen(path.c_str(), "rb"); + if (errno != 0) + return std::error_code{errno, std::generic_category()}; + return std::make_error_code(std::errc::io_error); + } + + std::expected openShared(std::filesystem::path const& path) + { + clearSystemError(); + auto* file = std::fopen(path.c_str(), "rb"); + if (file == nullptr) + return std::unexpected(lastSystemError()); + return file; } int seekFile(std::FILE* file, std::int64_t offset, int origin) @@ -134,7 +182,9 @@ namespace Utility bool SharedInputFileBuffer::open(std::filesystem::path const& path) { close(); - file_.reset(openShared(path)); + auto opened = openShared(path); + lastError_ = opened ? std::error_code{} : opened.error(); + file_.reset(opened.value_or(nullptr)); return isOpen(); } @@ -149,13 +199,29 @@ namespace Utility setg(buffer_.data(), buffer_.data(), buffer_.data()); } + std::error_code SharedInputFileBuffer::lastError() const + { + return lastError_; + } + + std::size_t SharedInputFileBuffer::readInto(char* destination, std::size_t count) + { + // The error indicator of the file is sticky, a later short read would take it for its own. + std::clearerr(file_.get()); + clearSystemError(); + const auto read = std::fread(destination, 1, count, file_.get()); + if (read < count && std::ferror(file_.get()) != 0) + lastError_ = lastSystemError(); + return read; + } + SharedInputFileBuffer::int_type SharedInputFileBuffer::underflow() { if (gptr() < egptr()) return traits_type::to_int_type(*gptr()); if (!file_) return traits_type::eof(); - const auto count = std::fread(buffer_.data(), 1, buffer_.size(), file_.get()); + const auto count = readInto(buffer_.data(), buffer_.size()); if (count == 0) return traits_type::eof(); setg(buffer_.data(), buffer_.data(), buffer_.data() + count); @@ -174,9 +240,7 @@ namespace Utility return buffered; // The rest goes straight into the destination, the buffer would only copy it once more. return buffered + - static_cast( - std::fread(destination + buffered, 1, static_cast(count - buffered), file_.get()) - ); + static_cast(readInto(destination + buffered, static_cast(count - buffered))); } SharedInputFileBuffer::pos_type @@ -222,4 +286,9 @@ namespace Utility { buffer_.close(); } + + std::error_code SharedInputFileStream::lastError() const + { + return buffer_.lastError(); + } } diff --git a/utility/test/utility/test_shared_input_file_stream.hpp b/utility/test/utility/test_shared_input_file_stream.hpp index 037da60c..374491fe 100644 --- a/utility/test/utility/test_shared_input_file_stream.hpp +++ b/utility/test/utility/test_shared_input_file_stream.hpp @@ -3,13 +3,27 @@ #include #include +#include + #include +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# include +#else +# include +#endif + #include #include #include #include +#include #include +#include +#include extern std::filesystem::path temporaryRoot; @@ -111,4 +125,156 @@ namespace Utility::Test EXPECT_FALSE(stream.is_open()); EXPECT_TRUE(stream.fail()); } + + TEST_F(SharedInputFileStreamTests, AFileReadToItsEndHasNoError) + { + SharedInputFileStream stream; + stream.open(path()); + ASSERT_TRUE(stream.is_open()); + + std::string read(content_.size() + 10u, '\0'); + stream.read(read.data(), static_cast(read.size())); + + EXPECT_EQ(static_cast(stream.gcount()), content_.size()); + EXPECT_TRUE(stream.eof()); + EXPECT_FALSE(stream.lastError()) << stream.lastError().message(); + } + + TEST_F(SharedInputFileStreamTests, AMissingFileTellsTheSystemsError) + { + SharedInputFileStream stream; + stream.open(directory_.path() / "missing.bin"); + +#ifdef _WIN32 + const auto windowsFileNotFound = 2; + EXPECT_EQ(stream.lastError(), (std::error_code{windowsFileNotFound, std::system_category()})) + << stream.lastError().message(); +#else + EXPECT_EQ(stream.lastError(), std::errc::no_such_file_or_directory) << stream.lastError().message(); +#endif + } + + TEST_F(SharedInputFileStreamTests, OpeningAgainForgetsTheLastError) + { + SharedInputFileStream stream; + stream.open(directory_.path() / "missing.bin"); + ASSERT_TRUE(stream.lastError()); + + stream.open(path()); + + EXPECT_TRUE(stream.is_open()); + EXPECT_FALSE(stream.lastError()) << stream.lastError().message(); + } + + TEST_F(SharedInputFileStreamTests, TheErrorStaysAfterTheStreamIsClosed) + { + SharedInputFileStream stream; + stream.open(directory_.path() / "missing.bin"); + + stream.close(); + + EXPECT_TRUE(stream.lastError()); + } + +#ifdef _WIN32 + namespace + { + struct TestHandleCloser + { + void operator()(HANDLE handle) const + { + ::CloseHandle(handle); + } + }; + using TestHandle = std::unique_ptr, TestHandleCloser>; + + TestHandle openForTest(std::filesystem::path const& path, DWORD shareMode) + { + const auto handle = ::CreateFileW( + path.c_str(), + GENERIC_READ | GENERIC_WRITE, + shareMode, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr + ); + return TestHandle{handle == INVALID_HANDLE_VALUE ? nullptr : handle}; + } + } + + TEST_F(SharedInputFileStreamTests, AFileAnotherProgramHoldsWithoutSharingTellsTheSharingViolation) + { + const auto holder = openForTest(path(), 0); + ASSERT_TRUE(holder); + + SharedInputFileStream stream; + stream.open(path()); + + EXPECT_FALSE(stream.is_open()); + EXPECT_EQ( + stream.lastError(), (std::error_code{static_cast(ERROR_SHARING_VIOLATION), std::system_category()}) + ) << stream.lastError().message(); + } + + TEST_F(SharedInputFileStreamTests, AReadOfARangeAnotherProgramLockedTellsTheLockViolation) + { + const auto holder = openForTest(path(), FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + ASSERT_TRUE(holder); + OVERLAPPED overlapped{}; + ASSERT_TRUE( + ::LockFileEx( + holder.get(), LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0, MAXDWORD, MAXDWORD, &overlapped + ) + ); + auto unlock = Nui::ScopeExit{[&holder]() noexcept + { + OVERLAPPED unlocked{}; + ::UnlockFileEx(holder.get(), 0, MAXDWORD, MAXDWORD, &unlocked); + }}; + + SharedInputFileStream stream; + stream.open(path()); + ASSERT_TRUE(stream.is_open()) << stream.lastError().message(); + std::array start{}; + stream.read(start.data(), start.size()); + + EXPECT_TRUE(stream.fail()); + EXPECT_EQ(stream.lastError(), (std::error_code{static_cast(ERROR_LOCK_VIOLATION), std::system_category()})) + << stream.lastError().message(); + } +#else + TEST_F(SharedInputFileStreamTests, AFileItMayNotReadTellsPermissionDenied) + { + if (::geteuid() == 0) + GTEST_SKIP() << "root can read any file"; + std::filesystem::permissions(path(), std::filesystem::perms::none); + auto restore = Nui::ScopeExit{[this]() noexcept + { + std::error_code errorCode{}; + std::filesystem::permissions(path(), std::filesystem::perms::owner_all, errorCode); + }}; + + SharedInputFileStream stream; + stream.open(path()); + + EXPECT_FALSE(stream.is_open()); + EXPECT_EQ(stream.lastError(), std::errc::permission_denied) << stream.lastError().message(); + } +#endif + +#ifdef __linux__ + TEST_F(SharedInputFileStreamTests, AFailedReadTellsTheSystemsError) + { + // Linux opens a directory for reading, only reading it fails. + SharedInputFileStream stream; + stream.open(directory_.path()); + ASSERT_TRUE(stream.is_open()) << stream.lastError().message(); + std::array start{}; + stream.read(start.data(), start.size()); + + EXPECT_TRUE(stream.fail()); + EXPECT_EQ(stream.lastError(), std::errc::is_a_directory) << stream.lastError().message(); + } +#endif } From 8a72e42109db2431fcb6e84eb85a0595d47f3d4b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:58:38 +0200 Subject: [PATCH 020/114] Classified system errors of local files as local file problems --- .../backend/sftp/local_file_problem.hpp | 20 +++ .../backend/sftp/local_file_problem.cpp | 60 +++++++++ .../test/backend/test_local_file_problem.hpp | 123 ++++++++++++++++++ 3 files changed, 203 insertions(+) diff --git a/backend/include/backend/sftp/local_file_problem.hpp b/backend/include/backend/sftp/local_file_problem.hpp index 6f70f09b..0c2763ef 100644 --- a/backend/include/backend/sftp/local_file_problem.hpp +++ b/backend/include/backend/sftp/local_file_problem.hpp @@ -1 +1,21 @@ #pragma once + +#include + +#include + +/** + * @brief What @p error says about a local file: Locked when another program holds it, PermissionDenied, NotFound, + * NoSpace, or Other for anything else and for no error at all. Takes the system's codes of every platform, + * a Windows code in std::system_category() on Windows, and errno values in either category. + */ +SharedData::LocalFileProblem localFileProblemOf(std::error_code const& error); + +namespace LocalFileProblemDetail +{ + /** + * @brief What the Windows system error @p code says about a local file, see localFileProblemOf. Available on every + * platform, so the table can be checked anywhere. + */ + SharedData::LocalFileProblem windowsFileProblemOf(int code); +} diff --git a/backend/source/backend/sftp/local_file_problem.cpp b/backend/source/backend/sftp/local_file_problem.cpp index 42b9d45f..dbd74672 100644 --- a/backend/source/backend/sftp/local_file_problem.cpp +++ b/backend/source/backend/sftp/local_file_problem.cpp @@ -1 +1,61 @@ #include + +namespace +{ + using SharedData::LocalFileProblem; + + namespace WindowsError + { + constexpr int fileNotFound = 2; + constexpr int pathNotFound = 3; + constexpr int accessDenied = 5; + constexpr int sharingViolation = 32; + constexpr int lockViolation = 33; + constexpr int diskFull = 112; + } + + LocalFileProblem portableFileProblemOf(std::error_code const& error) + { + if (error == std::errc::device_or_resource_busy || error == std::errc::text_file_busy) + return LocalFileProblem::Locked; + if (error == std::errc::permission_denied || error == std::errc::operation_not_permitted) + return LocalFileProblem::PermissionDenied; + if (error == std::errc::no_such_file_or_directory) + return LocalFileProblem::NotFound; + if (error == std::errc::no_space_on_device) + return LocalFileProblem::NoSpace; + return LocalFileProblem::Other; + } +} + +namespace LocalFileProblemDetail +{ + SharedData::LocalFileProblem windowsFileProblemOf(int code) + { + switch (code) + { + case WindowsError::sharingViolation: + case WindowsError::lockViolation: + return LocalFileProblem::Locked; + case WindowsError::accessDenied: + return LocalFileProblem::PermissionDenied; + case WindowsError::fileNotFound: + case WindowsError::pathNotFound: + return LocalFileProblem::NotFound; + case WindowsError::diskFull: + return LocalFileProblem::NoSpace; + default: + return LocalFileProblem::Other; + } + } +} + +SharedData::LocalFileProblem localFileProblemOf(std::error_code const& error) +{ +#ifdef _WIN32 + // Its values are Windows codes, some of which an errno comparison would take for unrelated errno values. + if (error.category() == std::system_category()) + return LocalFileProblemDetail::windowsFileProblemOf(error.value()); +#endif + return portableFileProblemOf(error); +} diff --git a/backend/test/backend/test_local_file_problem.hpp b/backend/test/backend/test_local_file_problem.hpp index 6f70f09b..26f6281f 100644 --- a/backend/test/backend/test_local_file_problem.hpp +++ b/backend/test/backend/test_local_file_problem.hpp @@ -1 +1,124 @@ #pragma once + +#include + +#include + +#include +#include +#include + +namespace Test +{ + using SharedData::LocalFileProblem; + + TEST(LocalFileProblemTests, ABusyFileIsLocked) + { + EXPECT_EQ( + localFileProblemOf(std::make_error_code(std::errc::device_or_resource_busy)), LocalFileProblem::Locked + ); + EXPECT_EQ(localFileProblemOf(std::make_error_code(std::errc::text_file_busy)), LocalFileProblem::Locked); + } + + TEST(LocalFileProblemTests, AccessAndPermissionErrorsAreDenied) + { + EXPECT_EQ( + localFileProblemOf(std::make_error_code(std::errc::permission_denied)), LocalFileProblem::PermissionDenied + ); + EXPECT_EQ( + localFileProblemOf(std::make_error_code(std::errc::operation_not_permitted)), + LocalFileProblem::PermissionDenied + ); + } + + TEST(LocalFileProblemTests, AMissingFileIsNotFound) + { + EXPECT_EQ( + localFileProblemOf(std::make_error_code(std::errc::no_such_file_or_directory)), LocalFileProblem::NotFound + ); + } + + TEST(LocalFileProblemTests, AFullDiskIsNoSpace) + { + EXPECT_EQ(localFileProblemOf(std::make_error_code(std::errc::no_space_on_device)), LocalFileProblem::NoSpace); + } + + TEST(LocalFileProblemTests, AnyOtherErrorIsOther) + { + EXPECT_EQ(localFileProblemOf(std::make_error_code(std::errc::io_error)), LocalFileProblem::Other); + EXPECT_EQ(localFileProblemOf(std::make_error_code(std::errc::is_a_directory)), LocalFileProblem::Other); + EXPECT_EQ(localFileProblemOf(std::make_error_code(std::errc::broken_pipe)), LocalFileProblem::Other); + EXPECT_EQ(localFileProblemOf(std::make_error_code(std::io_errc::stream)), LocalFileProblem::Other); + } + + TEST(LocalFileProblemTests, NoErrorIsOther) + { + EXPECT_EQ(localFileProblemOf(std::error_code{}), LocalFileProblem::Other); + } + + TEST(LocalFileProblemTests, WindowsCodesAreClassified) + { + using LocalFileProblemDetail::windowsFileProblemOf; + const auto sharingViolation = 32; + const auto lockViolation = 33; + const auto accessDenied = 5; + const auto fileNotFound = 2; + const auto pathNotFound = 3; + const auto diskFull = 112; + const auto invalidHandle = 6; + const auto invalidData = 13; + + EXPECT_EQ(windowsFileProblemOf(sharingViolation), LocalFileProblem::Locked); + EXPECT_EQ(windowsFileProblemOf(lockViolation), LocalFileProblem::Locked); + EXPECT_EQ(windowsFileProblemOf(accessDenied), LocalFileProblem::PermissionDenied); + EXPECT_EQ(windowsFileProblemOf(fileNotFound), LocalFileProblem::NotFound); + EXPECT_EQ(windowsFileProblemOf(pathNotFound), LocalFileProblem::NotFound); + EXPECT_EQ(windowsFileProblemOf(diskFull), LocalFileProblem::NoSpace); + EXPECT_EQ(windowsFileProblemOf(invalidHandle), LocalFileProblem::Other); + EXPECT_EQ(windowsFileProblemOf(invalidData), LocalFileProblem::Other); + EXPECT_EQ(windowsFileProblemOf(0), LocalFileProblem::Other); + } + +#ifdef _WIN32 + TEST(LocalFileProblemTests, SystemErrorsAreWindowsCodesOnWindows) + { + const auto sharingViolation = 32; + const auto lockViolation = 33; + const auto accessDenied = 5; + const auto pathNotFound = 3; + const auto diskFull = 112; + // EACCES as an errno value, no permission problem as a Windows code. + const auto invalidData = 13; + + EXPECT_EQ(localFileProblemOf({sharingViolation, std::system_category()}), LocalFileProblem::Locked); + EXPECT_EQ(localFileProblemOf({lockViolation, std::system_category()}), LocalFileProblem::Locked); + EXPECT_EQ(localFileProblemOf({accessDenied, std::system_category()}), LocalFileProblem::PermissionDenied); + EXPECT_EQ(localFileProblemOf({pathNotFound, std::system_category()}), LocalFileProblem::NotFound); + EXPECT_EQ(localFileProblemOf({diskFull, std::system_category()}), LocalFileProblem::NoSpace); + EXPECT_EQ(localFileProblemOf({invalidData, std::system_category()}), LocalFileProblem::Other); + } +#else + TEST(LocalFileProblemTests, SystemErrorsAreErrnoValuesElsewhere) + { + EXPECT_EQ(localFileProblemOf({EBUSY, std::system_category()}), LocalFileProblem::Locked); + EXPECT_EQ(localFileProblemOf({ETXTBSY, std::system_category()}), LocalFileProblem::Locked); + EXPECT_EQ(localFileProblemOf({EACCES, std::system_category()}), LocalFileProblem::PermissionDenied); + EXPECT_EQ(localFileProblemOf({EPERM, std::system_category()}), LocalFileProblem::PermissionDenied); + EXPECT_EQ(localFileProblemOf({ENOENT, std::system_category()}), LocalFileProblem::NotFound); + EXPECT_EQ(localFileProblemOf({ENOSPC, std::system_category()}), LocalFileProblem::NoSpace); + } + + TEST(LocalFileProblemTests, TheNumbersOfWindowsCodesAreErrnoValuesElsewhere) + { + const auto sharingViolation = 32; + const auto lockViolation = 33; + const auto accessDenied = 5; + const auto diskFull = 112; + + EXPECT_EQ(localFileProblemOf({sharingViolation, std::system_category()}), LocalFileProblem::Other); + EXPECT_EQ(localFileProblemOf({lockViolation, std::system_category()}), LocalFileProblem::Other); + EXPECT_EQ(localFileProblemOf({accessDenied, std::system_category()}), LocalFileProblem::Other); + EXPECT_EQ(localFileProblemOf({diskFull, std::system_category()}), LocalFileProblem::Other); + } +#endif +} From 5a141c070fcc841a2df8158edc8d3f4bd2737df2 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:01:03 +0200 Subject: [PATCH 021/114] Told the local problem of uploads that cannot inspect, open or read their file - The error carries the system's message as extra info - A big file transfer that cannot read fails with that error on its next step instead of CannotWorkFailedOperation --- .../include/backend/sftp/upload_operation.hpp | 2 + .../source/backend/sftp/upload_operation.cpp | 52 +++-- .../test/backend/test_local_file_problem.hpp | 221 ++++++++++++++++++ 3 files changed, 261 insertions(+), 14 deletions(-) diff --git a/backend/include/backend/sftp/upload_operation.hpp b/backend/include/backend/sftp/upload_operation.hpp index 3e1af413..23b544cd 100644 --- a/backend/include/backend/sftp/upload_operation.hpp +++ b/backend/include/backend/sftp/upload_operation.hpp @@ -254,6 +254,8 @@ class UploadOperation : public Operation /** @brief Lets others rename or delete the file while it uploads, which a std::ifstream refuses on Windows. */ Utility::SharedInputFileStream localFile_; bool isSymlink_{false}; + /** @brief Why the transfer could not read the local file, the error its next work step fails with. */ + std::optional readFailure_{}; /** @brief The state of the source right before it was opened, nothing when it could not be read. */ std::optional sourceBeforeOpening_{}; /** @brief The upload continued a temp file an earlier one left, whose content is not known to be the source's. */ diff --git a/backend/source/backend/sftp/upload_operation.cpp b/backend/source/backend/sftp/upload_operation.cpp index 7048c043..bdbc0d66 100644 --- a/backend/source/backend/sftp/upload_operation.cpp +++ b/backend/source/backend/sftp/upload_operation.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +35,19 @@ namespace return std::nullopt; return UploadOperation::SentSource{.size = static_cast(size), .modified = modified}; } + + /** + * @brief The error of a local file that could not be inspected, opened or read, with what @p cause says about it. + * Without a cause the problem is Other. + */ + Operation::Error localFileError(SharedData::OperationErrorType type, std::error_code const& cause) + { + return Operation::Error{ + .type = type, + .extraInfo = cause ? std::optional{Utility::errorMessage(cause)} : std::nullopt, + .localProblem = localFileProblemOf(cause), + }; + } } UploadOperation::UploadOperation(std::weak_ptr sftp, UploadOperationOptions options) @@ -199,7 +214,9 @@ std::expected UploadOperati if (asyncTransferContext_->hasEnded()) { if (asyncTransferContext_->outcome() != SecureShell::AsyncTransferContext::Outcome::Completed) - return enterErrorState(asyncTransferError(*asyncTransferContext_)); + return enterErrorState( + readFailure_.value_or(asyncTransferError(*asyncTransferContext_)) + ); leftToUpload_ = 0; options_.progressCallback(0ull, totalSize_, totalSize(), asyncTransferContext_->bytesPerSecond()); state_ = Finalizing; @@ -273,9 +290,8 @@ UploadOperation::commitFileToBuffer(SecureShell::IFileStream::SignedSizeType byt if (!localFile_.good()) { Log::error("UploadOperation write cycle stopped: localFile_.good() == false"); - std::ignore = enterErrorState({ - .type = SharedData::OperationErrorType::SourceFileNotGood, - }); + // Ends the transfer, the next work step fails with it. An error state entered here would not reach the owner. + readFailure_ = localFileError(ErrorType::SourceFileNotGood, localFile_.lastError()); return -1; } return localFile_.gcount(); @@ -320,7 +336,7 @@ std::expected UploadOperation::handleSymlinkInStra options_.localPath.toUtf8(), Utility::errorMessage(readError) ); - return std::unexpected(Error{.type = ErrorType::FileStatFailed, .extraInfo = Utility::errorMessage(readError)}); + return std::unexpected(localFileError(ErrorType::FileStatFailed, readError)); } const auto targetText = Paths::checkedNativeToUtf8(target); if (!targetText) @@ -414,7 +430,7 @@ std::expected UploadOperation::writeOnceInStrand() if (readCount == 0 && leftToUpload_ > 0) { Log::error("UploadOperation: The local file ended before its expected size was read."); - return enterErrorState({.type = ErrorType::SourceFileNotGood}); + return enterErrorState(localFileError(ErrorType::SourceFileNotGood, localFile_.lastError())); } leftToUpload_ -= readCount; @@ -639,16 +655,16 @@ std::expected UploadOperation::openLocalSource() if (linkStatus.type() == std::filesystem::file_type::not_found) { Log::error("UploadOperation: Local file '{}' does not exist.", options_.localPath.toUtf8()); - return std::unexpected(Error{.type = ErrorType::FileNotFound}); + return std::unexpected( + Error{.type = ErrorType::FileNotFound, .localProblem = SharedData::LocalFileProblem::NotFound} + ); } if (statusError) { Log::error( "UploadOperation: Cannot inspect '{}': {}", options_.localPath.toUtf8(), Utility::errorMessage(statusError) ); - return std::unexpected( - Error{.type = ErrorType::FileStatFailed, .extraInfo = Utility::errorMessage(statusError)} - ); + return std::unexpected(localFileError(ErrorType::FileStatFailed, statusError)); } if (!std::filesystem::is_regular_file(linkStatus) && !std::filesystem::is_symlink(linkStatus)) { @@ -667,12 +683,16 @@ std::expected UploadOperation::openLocalSource() if (status.type() == std::filesystem::file_type::not_found) { Log::error("UploadOperation: Local file '{}' does not exist.", options_.localPath.toUtf8()); - return std::unexpected(Error{.type = ErrorType::FileNotFound}); + return std::unexpected( + Error{.type = ErrorType::FileNotFound, .localProblem = SharedData::LocalFileProblem::NotFound} + ); } if (statusError || !std::filesystem::is_regular_file(status)) { Log::error("UploadOperation: Local path '{}' is not a regular file.", options_.localPath.toUtf8()); - return std::unexpected(Error{.type = ErrorType::NotAFile}); + return std::unexpected( + statusError ? localFileError(ErrorType::NotAFile, statusError) : Error{.type = ErrorType::NotAFile} + ); } // Taken before the file is opened, so a change while it is read differs from it. @@ -680,8 +700,12 @@ std::expected UploadOperation::openLocalSource() localFile_.open(localNativePath_); if (!localFile_.is_open()) { - Log::error("UploadOperation: Failed to open local file: {}", options_.localPath.toUtf8()); - return std::unexpected(Error{.type = ErrorType::OpenFailure}); + Log::error( + "UploadOperation: Failed to open local file '{}': {}", + options_.localPath.toUtf8(), + Utility::errorMessage(localFile_.lastError()) + ); + return std::unexpected(localFileError(ErrorType::OpenFailure, localFile_.lastError())); } localFile_.seekg(0, std::ios::end); leftToUpload_ = static_cast(localFile_.tellg()); diff --git a/backend/test/backend/test_local_file_problem.hpp b/backend/test/backend/test_local_file_problem.hpp index 26f6281f..b7d6e23e 100644 --- a/backend/test/backend/test_local_file_problem.hpp +++ b/backend/test/backend/test_local_file_problem.hpp @@ -1,12 +1,32 @@ #pragma once #include +#include +#include "real_server_tests.hpp" + +#include #include +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# include +#else +# include +#endif + #include +#include +#include +#include #include +#include +#include #include +#include +#include namespace Test { @@ -121,4 +141,205 @@ namespace Test EXPECT_EQ(localFileProblemOf({diskFull, std::system_category()}), LocalFileProblem::Other); } #endif + + class LocalFileProblemUploadTests : public RealServerTests + { + protected: + /** + * @brief Keeps a file from being opened for reading while it lives: on Windows another handle holds it + * without sharing, elsewhere it has no permissions. + */ + class UnreadableFile + { + public: + explicit UnreadableFile(std::filesystem::path path) + : path_{std::move(path)} + { +#ifdef _WIN32 + const auto handle = ::CreateFileW( + path_.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr + ); + holder_.reset(handle == INVALID_HANDLE_VALUE ? nullptr : handle); +#else + std::filesystem::permissions(path_, std::filesystem::perms::none); +#endif + } + ~UnreadableFile() + { +#ifndef _WIN32 + std::error_code errorCode{}; + std::filesystem::permissions(path_, std::filesystem::perms::owner_all, errorCode); +#endif + } + UnreadableFile(UnreadableFile const&) = delete; + UnreadableFile& operator=(UnreadableFile const&) = delete; + UnreadableFile(UnreadableFile&&) = delete; + UnreadableFile& operator=(UnreadableFile&&) = delete; + + private: +#ifdef _WIN32 + struct HandleCloser + { + void operator()(HANDLE handle) const + { + ::CloseHandle(handle); + } + }; + std::unique_ptr, HandleCloser> holder_{}; +#endif + std::filesystem::path path_; + }; + + /** @brief What an UnreadableFile is to an upload. */ +#ifdef _WIN32 + static constexpr auto unreadableProblem = LocalFileProblem::Locked; +#else + static constexpr auto unreadableProblem = LocalFileProblem::PermissionDenied; +#endif + + /** + * @brief Whether this process reads any file, so UnreadableFile cannot keep it from one. + */ + static bool readsAnyFile() + { +#ifdef _WIN32 + return false; +#else + return ::geteuid() == 0; +#endif + } + + static void writeSource(std::filesystem::path const& path, std::string const& content) + { + std::ofstream{path, std::ios_base::binary | std::ios_base::trunc} << content; + } + + /** + * @brief Works @p operation until it completes or fails. + */ + static std::expected + workUntilEnded(UploadOperation& operation, int maxIterations = 500) + { + auto status = UploadOperation::WorkStatus::MoreWork; + for ( + int iteration = 0; iteration < maxIterations && status != UploadOperation::WorkStatus::Complete; + ++iteration + ) + { + const auto worked = operation.work(); + if (!worked) + return worked; + status = *worked; + } + return status; + } + + Utility::TemporaryDirectory sources_{temporaryRoot, true}; + }; + + TEST_F(LocalFileProblemUploadTests, AnUploadOfAFileItCannotOpenTellsTheLocalProblem) + { + if (readsAnyFile()) + GTEST_SKIP() << "root reads any file"; + const auto source = sources_.path() / "unreadable.txt"; + writeSource(source, "unreadable"); + const UnreadableFile unreadable{source}; + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + UploadOperation operation{ + sftp, + { + .remotePath = remote("/home/test/unreadable.txt"), + .localPath = toLocal(source), + } + }; + + const auto worked = operation.work(); + + ASSERT_FALSE(worked.has_value()); + EXPECT_EQ(worked.error().type, SharedData::OperationErrorType::OpenFailure) << worked.error().toString(); + EXPECT_EQ(worked.error().localProblem, unreadableProblem) << worked.error().toString(); + EXPECT_TRUE(worked.error().extraInfo.has_value()); + } + + TEST_F(LocalFileProblemUploadTests, AnUploadOfAMissingFileTellsItIsNotFound) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + UploadOperation operation{ + sftp, + { + .remotePath = remote("/home/test/missing.txt"), + .localPath = toLocal(sources_.path() / "missing.txt"), + } + }; + + const auto worked = operation.work(); + + ASSERT_FALSE(worked.has_value()); + EXPECT_EQ(worked.error().type, SharedData::OperationErrorType::FileNotFound); + EXPECT_EQ(worked.error().localProblem, LocalFileProblem::NotFound); + } + + TEST_F(LocalFileProblemUploadTests, AFileThatShrinksWhileItIsSentInChunksFailsWithALocalProblem) + { + const auto source = sources_.path() / "shrinking.bin"; + writeSource(source, std::string(1024u * 1024u, 's')); + bool shrunk = false; + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + UploadOperation operation{ + sftp, + { + .progressCallback = + [&source, &shrunk](auto, auto, auto, auto) + { + std::error_code errorCode{}; + if (!std::exchange(shrunk, true)) + std::filesystem::resize_file(source, 0, errorCode); + }, + .remotePath = remote("/home/test/shrinking.bin"), + .localPath = toLocal(source), + } + }; + + const auto ended = workUntilEnded(operation); + + EXPECT_TRUE(shrunk); + ASSERT_FALSE(ended.has_value()); + EXPECT_EQ(ended.error().type, SharedData::OperationErrorType::SourceFileNotGood) << ended.error().toString(); + EXPECT_EQ(ended.error().localProblem, LocalFileProblem::Other); + } + + TEST_F(LocalFileProblemUploadTests, AFileThatShrinksBeforeABigTransferReadsItFailsWithALocalProblem) + { + const auto source = sources_.path() / "shrinking.bin"; + writeSource(source, std::string(256u * 1024u, 's')); + bool shrunk = false; + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + UploadOperation operation{ + sftp, + { + // The first progress comes on the strand right after the transfer started, before it read anything. + .progressCallback = + [&source, &shrunk](auto, auto, auto, auto) + { + std::error_code errorCode{}; + if (!std::exchange(shrunk, true)) + std::filesystem::resize_file(source, 0, errorCode); + }, + .remotePath = remote("/home/test/shrinking.bin"), + .localPath = toLocal(source), + .bigFileOptimized = true, + } + }; + + const auto ended = workUntilEnded(operation); + + EXPECT_TRUE(shrunk); + ASSERT_FALSE(ended.has_value()); + EXPECT_EQ(ended.error().type, SharedData::OperationErrorType::SourceFileNotGood) << ended.error().toString(); + EXPECT_EQ(ended.error().localProblem, LocalFileProblem::Other); + } } From a912154461280ed4c9ac2926f161e1785371bbe5 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:03:31 +0200 Subject: [PATCH 022/114] Told the local problem or the server's code of each entry a bulk skipped --- .../backend/sftp/bulk_transfer_operation.cpp | 30 ++- .../test/backend/test_local_file_problem.hpp | 174 ++++++++++++------ 2 files changed, 149 insertions(+), 55 deletions(-) diff --git a/backend/source/backend/sftp/bulk_transfer_operation.cpp b/backend/source/backend/sftp/bulk_transfer_operation.cpp index 66c83ae1..cbbb4b97 100644 --- a/backend/source/backend/sftp/bulk_transfer_operation.cpp +++ b/backend/source/backend/sftp/bulk_transfer_operation.cpp @@ -4,9 +4,33 @@ #include #include +#include using namespace std::chrono_literals; +namespace +{ + /** + * @brief The skipped entry at @p index with why @p error says it was skipped: the local problem of a local + * failure, the server's status code of one the server answered with. + */ + SharedData::IndexedFailure indexedFailureOf(std::size_t index, Operation::Error const& error) + { + const auto serverCode = [&error]() -> std::optional + { + if (!error.sftpError || error.sftpError->sftpError == SSH_FX_OK) + return std::nullopt; + return error.sftpError->sftpError; + }(); + return SharedData::IndexedFailure{ + .index = static_cast(index), + .type = error.type, + .localProblem = error.localProblem, + .sftpErrorCode = serverCode, + }; + } +} + BulkTransferOperation::BulkTransferOperation(std::weak_ptr sftp, CommonOptions common) : Operation{} , sftp_{std::move(sftp)} @@ -238,7 +262,7 @@ std::expected BulkTransferOperation::fillSlo { Log::warn("{}: Skipping '{}': {}", logName(), displayPath(index), paths.error().toString()); failedEntries_.emplace_back(failureSource(index), paths.error()); - indexedFailures_.push_back({.index = static_cast(index), .type = paths.error().type}); + indexedFailures_.push_back(indexedFailureOf(index, paths.error())); completedBytes_ += entry.isRegularFile() ? entry.size : 0u; ++completedEntries_; continue; @@ -255,7 +279,7 @@ std::expected BulkTransferOperation::fillSlo return std::unexpected(result.error()); Log::warn("{}: Skipping directory '{}': {}", logName(), displayPath(index), result.error().toString()); failedEntries_.emplace_back(failureSource(index), result.error()); - indexedFailures_.push_back({.index = static_cast(index), .type = result.error().type}); + indexedFailures_.push_back(indexedFailureOf(index, result.error())); } else { @@ -338,7 +362,7 @@ std::expected BulkTransferOperation::stepSlo { // Not critical for the bulk as a whole: record the entry for the user and move on. failedEntries_.emplace_back(failureSource(slot.paths), error); - indexedFailures_.push_back({.index = static_cast(slot.entryIndex), .type = error.type}); + indexedFailures_.push_back(indexedFailureOf(slot.entryIndex, error)); finishSlot(slot, false); if (common_.failFast) { diff --git a/backend/test/backend/test_local_file_problem.hpp b/backend/test/backend/test_local_file_problem.hpp index b7d6e23e..4298ef88 100644 --- a/backend/test/backend/test_local_file_problem.hpp +++ b/backend/test/backend/test_local_file_problem.hpp @@ -1,10 +1,10 @@ #pragma once +#include #include #include -#include "real_server_tests.hpp" - #include +#include "real_server_tests.hpp" #include @@ -27,6 +27,7 @@ #include #include #include +#include namespace Test { @@ -217,14 +218,11 @@ namespace Test /** * @brief Works @p operation until it completes or fails. */ - static std::expected - workUntilEnded(UploadOperation& operation, int maxIterations = 500) + static std::expected + workUntilEnded(Operation& operation, int maxIterations = 2000) { - auto status = UploadOperation::WorkStatus::MoreWork; - for ( - int iteration = 0; iteration < maxIterations && status != UploadOperation::WorkStatus::Complete; - ++iteration - ) + auto status = Operation::WorkStatus::MoreWork; + for (int iteration = 0; iteration < maxIterations && status != Operation::WorkStatus::Complete; ++iteration) { const auto worked = operation.work(); if (!worked) @@ -234,6 +232,41 @@ namespace Test return status; } + /** @brief How the upload of a shrinking file ended, and whether it shrank. */ + struct ShrunkUpload + { + std::expected ended; + bool shrunk; + }; + + /** + * @brief Uploads a file of @p size bytes that shrinks to nothing at the upload's first progress. A big file + * transfer tells it on the strand right after it started, before it read anything. + */ + ShrunkUpload + uploadShrinking(std::weak_ptr sftp, std::size_t size, bool bigFileOptimized) const + { + const auto source = sources_.path() / "shrinking.bin"; + writeSource(source, std::string(size, 's')); + bool shrunk = false; + UploadOperation operation{ + std::move(sftp), + { + .progressCallback = + [&source, &shrunk](auto, auto, auto, auto) + { + std::error_code errorCode{}; + if (!std::exchange(shrunk, true)) + std::filesystem::resize_file(source, 0, errorCode); + }, + .remotePath = remote("/home/test/shrinking.bin"), + .localPath = toLocal(source), + .bigFileOptimized = bigFileOptimized, + } + }; + return ShrunkUpload{.ended = workUntilEnded(operation), .shrunk = shrunk}; + } + Utility::TemporaryDirectory sources_{temporaryRoot, true}; }; @@ -283,63 +316,100 @@ namespace Test TEST_F(LocalFileProblemUploadTests, AFileThatShrinksWhileItIsSentInChunksFailsWithALocalProblem) { - const auto source = sources_.path() / "shrinking.bin"; - writeSource(source, std::string(1024u * 1024u, 's')); - bool shrunk = false; CREATE_SERVER_AND_JOINER(sftpServer); auto [session, sftp] = createSftpSession(serverStartResult->port); - UploadOperation operation{ - sftp, - { - .progressCallback = - [&source, &shrunk](auto, auto, auto, auto) - { - std::error_code errorCode{}; - if (!std::exchange(shrunk, true)) - std::filesystem::resize_file(source, 0, errorCode); - }, - .remotePath = remote("/home/test/shrinking.bin"), - .localPath = toLocal(source), - } - }; - const auto ended = workUntilEnded(operation); + const auto upload = uploadShrinking(sftp, 1024u * 1024u, false); - EXPECT_TRUE(shrunk); - ASSERT_FALSE(ended.has_value()); - EXPECT_EQ(ended.error().type, SharedData::OperationErrorType::SourceFileNotGood) << ended.error().toString(); - EXPECT_EQ(ended.error().localProblem, LocalFileProblem::Other); + EXPECT_TRUE(upload.shrunk); + ASSERT_FALSE(upload.ended.has_value()); + EXPECT_EQ(upload.ended.error().type, SharedData::OperationErrorType::SourceFileNotGood) + << upload.ended.error().toString(); + EXPECT_EQ(upload.ended.error().localProblem, LocalFileProblem::Other); } TEST_F(LocalFileProblemUploadTests, AFileThatShrinksBeforeABigTransferReadsItFailsWithALocalProblem) { - const auto source = sources_.path() / "shrinking.bin"; - writeSource(source, std::string(256u * 1024u, 's')); - bool shrunk = false; CREATE_SERVER_AND_JOINER(sftpServer); auto [session, sftp] = createSftpSession(serverStartResult->port); - UploadOperation operation{ - sftp, + + const auto upload = uploadShrinking(sftp, 256u * 1024u, true); + + EXPECT_TRUE(upload.shrunk); + ASSERT_FALSE(upload.ended.has_value()); + EXPECT_EQ(upload.ended.error().type, SharedData::OperationErrorType::SourceFileNotGood) + << upload.ended.error().toString(); + EXPECT_EQ(upload.ended.error().localProblem, LocalFileProblem::Other); + } + + TEST_F(LocalFileProblemUploadTests, ABulkUploadTellsTheLocalProblemOfAnEntryItCannotOpen) + { + if (readsAnyFile()) + GTEST_SKIP() << "root reads any file"; + writeSource(sources_.path() / "unreadable.txt", "unreadable"); + writeSource(sources_.path() / "readable.txt", "readable"); + const UnreadableFile unreadable{sources_.path() / "unreadable.txt"}; + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + BulkUploadOperation operation{ + sftp, {.remotePath = remote("/home/test"), .localPath = toLocal(sources_.path()), .failFast = false} + }; + operation.setPrescannedFileList({ { - // The first progress comes on the strand right after the transfer started, before it read anything. - .progressCallback = - [&source, &shrunk](auto, auto, auto, auto) - { - std::error_code errorCode{}; - if (!std::exchange(shrunk, true)) - std::filesystem::resize_file(source, 0, errorCode); - }, - .remotePath = remote("/home/test/shrinking.bin"), - .localPath = toLocal(source), - .bigFileOptimized = true, - } + .source = toLocal(sources_.path() / "unreadable.txt"), + .destination = remote("/home/test/unreadable.txt"), + .sizeBytes = 10u, + }, + { + .source = toLocal(sources_.path() / "readable.txt"), + .destination = remote("/home/test/readable.txt"), + .sizeBytes = 8u, + }, + }); + + const auto ended = workUntilEnded(operation); + + ASSERT_TRUE(ended.has_value()) << ended.error().toString(); + EXPECT_EQ(*ended, Operation::WorkStatus::Complete); + ASSERT_EQ(operation.indexedFailures().size(), 1u); + auto const& failure = operation.indexedFailures().front(); + EXPECT_EQ(failure.index, 0u); + EXPECT_EQ(failure.type, SharedData::OperationErrorType::OpenFailure); + EXPECT_EQ(failure.localProblem, unreadableProblem); + EXPECT_FALSE(failure.sftpErrorCode.has_value()); + } + + TEST_F(LocalFileProblemUploadTests, ABulkUploadTellsTheServersCodeForAnEntryTheServerRefused) + { + writeSource(sources_.path() / "refused.txt", "refused"); + writeSource(sources_.path() / "accepted.txt", "accepted"); + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + BulkUploadOperation operation{ + sftp, {.remotePath = remote("/home/test"), .localPath = toLocal(sources_.path()), .failFast = false} }; + operation.setPrescannedFileList({ + { + .source = toLocal(sources_.path() / "refused.txt"), + .destination = remote("/home/test/no_such_directory/refused.txt"), + .sizeBytes = 7u, + }, + { + .source = toLocal(sources_.path() / "accepted.txt"), + .destination = remote("/home/test/accepted.txt"), + .sizeBytes = 8u, + }, + }); const auto ended = workUntilEnded(operation); - EXPECT_TRUE(shrunk); - ASSERT_FALSE(ended.has_value()); - EXPECT_EQ(ended.error().type, SharedData::OperationErrorType::SourceFileNotGood) << ended.error().toString(); - EXPECT_EQ(ended.error().localProblem, LocalFileProblem::Other); + ASSERT_TRUE(ended.has_value()) << ended.error().toString(); + EXPECT_EQ(*ended, Operation::WorkStatus::Complete); + ASSERT_EQ(operation.indexedFailures().size(), 1u); + auto const& failure = operation.indexedFailures().front(); + EXPECT_EQ(failure.index, 0u); + EXPECT_EQ(failure.type, SharedData::OperationErrorType::SftpError); + EXPECT_EQ(failure.sftpErrorCode, SSH_FX_NO_SUCH_FILE); + EXPECT_FALSE(failure.localProblem.has_value()); } } From 94334b217962c93731e1964d33eb88bedb154b67 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 14:59:09 +0200 Subject: [PATCH 023/114] Mapped operation errors and skipped bulk entries to upload failure reasons --- .../backend/file_tracking/failure_reason.hpp | 50 +++ .../backend/file_tracking/failure_reason.cpp | 249 ++++++++++++ backend/test/backend/test_failure_reason.hpp | 383 ++++++++++++++++++ 3 files changed, 682 insertions(+) diff --git a/backend/include/backend/file_tracking/failure_reason.hpp b/backend/include/backend/file_tracking/failure_reason.hpp index 6f70f09b..b29cf755 100644 --- a/backend/include/backend/file_tracking/failure_reason.hpp +++ b/backend/include/backend/file_tracking/failure_reason.hpp @@ -1 +1,51 @@ #pragma once + +#include +#include +#include +#include + +#include + +namespace FileTracking +{ + /** + * @brief Why an upload that ended with @p error failed. A local problem of the source comes first, then a lost + * connection, then the server's answer. + */ + UploadFailureInfo failureInfoOf(SharedData::OperationError const& error); + + /** + * @brief Why a bulk upload left out the entry @p failure tells of. + */ + UploadFailureInfo failureInfoOf(SharedData::IndexedFailure const& failure); + + /** + * @brief The entries below the directory @p directory that its upload, which ended as @p completed, left out. An + * entry whose name is no local path is told as the directory itself. + */ + std::vector + failedEntriesOf(SharedData::OperationCompleted const& completed, Paths::LocalPath const& directory); + + /** + * @brief How the single upload of @p source ended as @p completed, without what the server has at its destination + * and the files it sent. + * @param isDirectory Whether @p source is a directory, its left out entries are told then. + */ + UploadEnd + uploadEndOf(SharedData::OperationCompleted const& completed, Paths::LocalPath const& source, bool isDirectory); + + /** + * @brief How each upload of a bulk of files ended as the bulk @p completed, by the index of its source in + * @p sources, without what the server has at its destination and the files it sent. A source that was + * gone when its turn came ends as cancelled, its deletion is mirrored apart. + */ + std::vector + entryEndsOf(SharedData::OperationCompleted const& completed, std::vector const& sources); + + /** + * @brief Whether the server is asked what it has at the destination of an upload that ended as @p ended: after a + * completed upload of anything but a directory, and after one whose destination changed on the server. + */ + bool asksDestinationState(UploadEnd const& ended, bool isDirectory); +} diff --git a/backend/source/backend/file_tracking/failure_reason.cpp b/backend/source/backend/file_tracking/failure_reason.cpp index 5b5d094c..63e6a169 100644 --- a/backend/source/backend/file_tracking/failure_reason.cpp +++ b/backend/source/backend/file_tracking/failure_reason.cpp @@ -1 +1,250 @@ #include + +#include +#include + +#include +#include +#include + +namespace FileTracking +{ + namespace + { + using SharedData::LocalFileProblem; + using SharedData::OperationCompletionReason; + using SharedData::OperationErrorType; + using SharedData::UploadFailureReason; + + /** @brief SSH_FX_NO_SPACE_ON_FILESYSTEM of SFTP version 5, which libssh does not define. */ + constexpr int noSpaceOnFilesystem = 14; + /** @brief SSH_FX_QUOTA_EXCEEDED of SFTP version 5, which libssh does not define. */ + constexpr int quotaExceeded = 15; + + std::optional unlessEmpty(std::string const& text) + { + if (text.empty()) + return std::nullopt; + return text; + } + + UploadFailureReason localReasonOf(LocalFileProblem problem) + { + switch (problem) + { + case LocalFileProblem::Locked: + return UploadFailureReason::LockedLocally; + case LocalFileProblem::PermissionDenied: + return UploadFailureReason::PermissionDeniedLocally; + case LocalFileProblem::NotFound: + case LocalFileProblem::Other: + return UploadFailureReason::UnreadableLocally; + case LocalFileProblem::NoSpace: + break; + } + return UploadFailureReason::Unknown; + } + + bool isConnectionLoss(OperationErrorType type) + { + return type == OperationErrorType::ChannelClosed || type == OperationErrorType::FutureTimeout; + } + + /** + * @brief The reason of an SFTP status code the server answered with, nothing for none. + */ + std::optional serverReasonOf(int sftpCode) + { + switch (sftpCode) + { + case SSH_FX_OK: + return std::nullopt; + case SSH_FX_PERMISSION_DENIED: + return UploadFailureReason::PermissionDeniedOnServer; + case noSpaceOnFilesystem: + case quotaExceeded: + return UploadFailureReason::NoSpaceOnServer; + case SSH_FX_NO_CONNECTION: + case SSH_FX_CONNECTION_LOST: + return UploadFailureReason::ConnectionProblem; + default: + return UploadFailureReason::ServerRefused; + } + } + + /** + * @brief Why an SFTP call failed that did not get an answer from the server, nothing when it got one. + */ + std::optional unansweredReasonOf(SecureShell::SftpError const& error) + { + switch (error.wrapperError) + { + case SecureShell::WrapperErrors::OwnerNull: + case SecureShell::WrapperErrors::SharedPtrDestroyed: + return UploadFailureReason::ConnectionProblem; + case SecureShell::WrapperErrors::LocalIoFailure: + return UploadFailureReason::UnreadableLocally; + case SecureShell::WrapperErrors::InvalidPath: + return UploadFailureReason::Unknown; + default: + break; + } + if (error.sftpError == SSH_FX_OK && error.sshError != 0) + return UploadFailureReason::ConnectionProblem; + return std::nullopt; + } + + /** + * @brief The text an error has besides its type: what the code that raised it added, else the SFTP message. + */ + std::optional textOf(SharedData::OperationError const& error) + { + if (error.extraInfo && !error.extraInfo->empty()) + return error.extraInfo; + if (error.sftpError) + return unlessEmpty(error.sftpError->message); + return std::nullopt; + } + + std::optional serverTextOf(SecureShell::SftpError const& error) + { + if (!error.message.empty()) + return error.message; + return SecureShell::SftpError::sftpErrorToComprehensible(error.sftpError); + } + + /** + * @brief Why the operation that ended as @p completed failed, unknown when it did not tell. + */ + UploadFailureInfo failureOfOperation(SharedData::OperationCompleted const& completed) + { + if (!completed.error) + return {}; + return FileTracking::failureInfoOf(*completed.error); + } + } + + UploadFailureInfo failureInfoOf(SharedData::OperationError const& error) + { + if (error.localProblem) + return {.reason = localReasonOf(*error.localProblem), .detail = textOf(error)}; + if (isConnectionLoss(error.type)) + return {.reason = UploadFailureReason::ConnectionProblem, .detail = textOf(error)}; + if (error.sftpError) + { + if (const auto unanswered = unansweredReasonOf(*error.sftpError)) + return {.reason = *unanswered, .detail = textOf(error)}; + if (const auto answered = serverReasonOf(error.sftpError->sftpError)) + return {.reason = *answered, .detail = serverTextOf(*error.sftpError)}; + } + return {.reason = UploadFailureReason::Unknown, .detail = error.toString()}; + } + + UploadFailureInfo failureInfoOf(SharedData::IndexedFailure const& failure) + { + if (failure.localProblem) + return {.reason = localReasonOf(*failure.localProblem)}; + if (isConnectionLoss(failure.type)) + return {.reason = UploadFailureReason::ConnectionProblem}; + if (failure.sftpErrorCode) + { + if (const auto answered = serverReasonOf(*failure.sftpErrorCode)) + return { + .reason = *answered, + .detail = SecureShell::SftpError::sftpErrorToComprehensible(*failure.sftpErrorCode), + }; + } + return { + .reason = UploadFailureReason::Unknown, + .detail = std::string{boost::describe::enum_to_string(failure.type, "UnknownError")}, + }; + } + + std::vector + failedEntriesOf(SharedData::OperationCompleted const& completed, Paths::LocalPath const& directory) + { + std::vector failed; + failed.reserve(completed.failedEntries.size()); + for (auto const& [path, error] : completed.failedEntries) + { + if (error.type == OperationErrorType::ChangedSinceCompare) + continue; + failed.push_back({ + .path = Paths::LocalPath::fromUtf8(path, directory.platform()).value_or(directory), + .failure = failureInfoOf(error), + }); + } + return failed; + } + + UploadEnd + uploadEndOf(SharedData::OperationCompleted const& completed, Paths::LocalPath const& source, bool isDirectory) + { + if (completed.error && completed.error->type == OperationErrorType::ChangedSinceCompare) + return {.reason = completed.reason, .changedOnServer = true}; + switch (completed.reason) + { + case OperationCompletionReason::Failed: + return {.reason = OperationCompletionReason::Failed, .failure = failureOfOperation(completed)}; + case OperationCompletionReason::Completed: + if (isDirectory) + return { + .reason = OperationCompletionReason::Completed, + .failedEntries = failedEntriesOf(completed, source), + }; + break; + case OperationCompletionReason::Canceled: + break; + } + return {.reason = completed.reason}; + } + + std::vector + entryEndsOf(SharedData::OperationCompleted const& completed, std::vector const& sources) + { + if (completed.reason != OperationCompletionReason::Completed) + { + UploadEnd ended{.reason = completed.reason}; + if (completed.reason == OperationCompletionReason::Failed) + ended.failure = failureOfOperation(completed); + return std::vector(sources.size(), ended); + } + std::map skipped; + for (auto const& failure : completed.indexedFailures) + skipped.emplace(static_cast(failure.index), failure); + std::map failedBySource; + for (auto const& [path, error] : completed.failedEntries) + failedBySource.emplace(path, error); + + const auto endOf = [&skipped, &failedBySource, &sources](std::size_t index) -> UploadEnd + { + const auto found = skipped.find(index); + if (found == skipped.end()) + return {.reason = OperationCompletionReason::Completed}; + auto const& failure = found->second; + if (failure.type == OperationErrorType::ChangedSinceCompare) + return {.reason = OperationCompletionReason::Failed, .changedOnServer = true}; + const auto failed = failedBySource.find(sources[index].toUtf8()); + if (failed == failedBySource.end()) + { + // Left out without an error: its source was gone. + if (failure.type == OperationErrorType::FileNotFound) + return {.reason = OperationCompletionReason::Canceled}; + return {.reason = OperationCompletionReason::Failed, .failure = failureInfoOf(failure)}; + } + return {.reason = OperationCompletionReason::Failed, .failure = failureInfoOf(failed->second)}; + }; + std::vector ends; + ends.reserve(sources.size()); + for (std::size_t index = 0; index < sources.size(); ++index) + ends.push_back(endOf(index)); + return ends; + } + + bool asksDestinationState(UploadEnd const& ended, bool isDirectory) + { + if (ended.changedOnServer) + return true; + return ended.reason == OperationCompletionReason::Completed && !isDirectory; + } +} diff --git a/backend/test/backend/test_failure_reason.hpp b/backend/test/backend/test_failure_reason.hpp index 6f70f09b..57829d46 100644 --- a/backend/test/backend/test_failure_reason.hpp +++ b/backend/test/backend/test_failure_reason.hpp @@ -1 +1,384 @@ #pragma once + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace Test::FailureReason +{ + using namespace FileTracking; + using SharedData::LocalFileProblem; + using SharedData::OperationCompletionReason; + using SharedData::OperationErrorType; + using SharedData::UploadFailureReason; + + namespace FailureReasonTestDetail + { + inline Paths::LocalPath local(std::string_view path) + { + return Paths::LocalPath::fromUtf8(path, Paths::LocalPlatform::Posix).value(); + } + + inline SharedData::OperationError sftpFailure(int code, std::string message = {}) + { + return { + .type = OperationErrorType::SftpError, + .sftpError = SecureShell::SftpError{.message = std::move(message), .sftpError = code}, + }; + } + + inline SharedData::OperationCompleted + completedAs(OperationCompletionReason reason, std::optional error = std::nullopt) + { + return { + .reason = reason, + .operationId = Ids::generateOperationId(), + .completionTime = std::chrono::system_clock::now(), + .error = std::move(error), + }; + } + } + + using namespace FailureReasonTestDetail; + + TEST(FailureReasonTests, ALockedSourceIsLockedLocallyWithTheSystemsMessage) + { + const auto failure = failureInfoOf( + SharedData::OperationError{ + .type = OperationErrorType::OpenFailure, + .extraInfo = "The process cannot access the file", + .localProblem = LocalFileProblem::Locked, + } + ); + EXPECT_EQ(failure.reason, UploadFailureReason::LockedLocally); + EXPECT_EQ(failure.detail, "The process cannot access the file"); + } + + TEST(FailureReasonTests, LocalProblemsMapToTheirLocalReasons) + { + const auto reasonOf = [](LocalFileProblem problem) + { + return failureInfoOf( + SharedData::OperationError{.type = OperationErrorType::OpenFailure, .localProblem = problem} + ) + .reason; + }; + EXPECT_EQ(reasonOf(LocalFileProblem::Locked), UploadFailureReason::LockedLocally); + EXPECT_EQ(reasonOf(LocalFileProblem::PermissionDenied), UploadFailureReason::PermissionDeniedLocally); + EXPECT_EQ(reasonOf(LocalFileProblem::NotFound), UploadFailureReason::UnreadableLocally); + EXPECT_EQ(reasonOf(LocalFileProblem::Other), UploadFailureReason::UnreadableLocally); + EXPECT_EQ(reasonOf(LocalFileProblem::NoSpace), UploadFailureReason::Unknown); + } + + TEST(FailureReasonTests, ALocalProblemComesBeforeTheServersAnswer) + { + auto error = sftpFailure(3); + error.localProblem = LocalFileProblem::PermissionDenied; + EXPECT_EQ(failureInfoOf(error).reason, UploadFailureReason::PermissionDeniedLocally); + } + + TEST(FailureReasonTests, PermissionDeniedByTheServerIsCodeThree) + { + const auto failure = failureInfoOf(sftpFailure(3)); + EXPECT_EQ(failure.reason, UploadFailureReason::PermissionDeniedOnServer); + } + + TEST(FailureReasonTests, NoSpaceAndAnExceededQuotaAreNoSpaceOnTheServer) + { + EXPECT_EQ(failureInfoOf(sftpFailure(14)).reason, UploadFailureReason::NoSpaceOnServer); + EXPECT_EQ(failureInfoOf(sftpFailure(15)).reason, UploadFailureReason::NoSpaceOnServer); + } + + TEST(FailureReasonTests, OtherCodesAreRefusedByTheServerWithItsMessage) + { + const auto failure = failureInfoOf(sftpFailure(4, "Disk quota exceeded")); + EXPECT_EQ(failure.reason, UploadFailureReason::ServerRefused); + EXPECT_EQ(failure.detail, "Disk quota exceeded"); + + const auto noSuchFile = failureInfoOf(sftpFailure(2)); + EXPECT_EQ(noSuchFile.reason, UploadFailureReason::ServerRefused); + EXPECT_EQ(noSuchFile.detail, "No such file"); + } + + TEST(FailureReasonTests, AClosedChannelATimeoutAndALostConnectionAreConnectionProblems) + { + const auto closed = failureInfoOf( + SharedData::OperationError{ + .type = OperationErrorType::ChannelClosed, + .extraInfo = "The SFTP channel was closed", + } + ); + EXPECT_EQ(closed.reason, UploadFailureReason::ConnectionProblem); + EXPECT_EQ(closed.detail, "The SFTP channel was closed"); + EXPECT_EQ( + failureInfoOf(SharedData::OperationError{.type = OperationErrorType::FutureTimeout}).reason, + UploadFailureReason::ConnectionProblem + ); + EXPECT_EQ(failureInfoOf(sftpFailure(6)).reason, UploadFailureReason::ConnectionProblem); + EXPECT_EQ(failureInfoOf(sftpFailure(7)).reason, UploadFailureReason::ConnectionProblem); + } + + TEST(FailureReasonTests, AGoneSessionOrAnSshErrorIsAConnectionProblem) + { + const auto wrapped = [](SecureShell::WrapperErrors wrapperError) + { + return failureInfoOf( + SharedData::OperationError{ + .type = OperationErrorType::SftpError, + .sftpError = SecureShell::SftpError{.message = "gone", .wrapperError = wrapperError}, + } + ) + .reason; + }; + EXPECT_EQ(wrapped(SecureShell::WrapperErrors::SharedPtrDestroyed), UploadFailureReason::ConnectionProblem); + EXPECT_EQ(wrapped(SecureShell::WrapperErrors::OwnerNull), UploadFailureReason::ConnectionProblem); + EXPECT_EQ(wrapped(SecureShell::WrapperErrors::LocalIoFailure), UploadFailureReason::UnreadableLocally); + EXPECT_EQ( + failureInfoOf( + SharedData::OperationError{ + .type = OperationErrorType::SftpError, + .sftpError = SecureShell::SftpError{.message = "Socket error", .sshError = -1}, + } + ) + .reason, + UploadFailureReason::ConnectionProblem + ); + } + + TEST(FailureReasonTests, AnythingElseIsUnknownWithWhatTheErrorTells) + { + const SharedData::OperationError error{ + .type = OperationErrorType::OperationNotPossibleOnFileType, + .extraInfo = "A socket", + }; + const auto failure = failureInfoOf(error); + EXPECT_EQ(failure.reason, UploadFailureReason::Unknown); + ASSERT_TRUE(failure.detail.has_value()); + EXPECT_EQ(*failure.detail, error.toString()); + EXPECT_NE(failure.detail->find("A socket"), std::string::npos); + } + + TEST(FailureReasonTests, AnIndexedFailureMapsLikeTheErrorItStandsFor) + { + const auto indexed = [](SharedData::IndexedFailure failure) + { + return failureInfoOf(failure); + }; + EXPECT_EQ( + indexed({.type = OperationErrorType::OpenFailure, .localProblem = LocalFileProblem::Locked}).reason, + UploadFailureReason::LockedLocally + ); + EXPECT_EQ( + indexed({.type = OperationErrorType::OpenFailure, .localProblem = LocalFileProblem::PermissionDenied}) + .reason, + UploadFailureReason::PermissionDeniedLocally + ); + EXPECT_EQ( + indexed({.type = OperationErrorType::SourceFileNotGood, .localProblem = LocalFileProblem::Other}).reason, + UploadFailureReason::UnreadableLocally + ); + EXPECT_EQ( + indexed({.type = OperationErrorType::SftpError, .sftpErrorCode = 3}).reason, + UploadFailureReason::PermissionDeniedOnServer + ); + EXPECT_EQ( + indexed({.type = OperationErrorType::SftpError, .sftpErrorCode = 14}).reason, + UploadFailureReason::NoSpaceOnServer + ); + EXPECT_EQ( + indexed({.type = OperationErrorType::SftpError, .sftpErrorCode = 15}).reason, + UploadFailureReason::NoSpaceOnServer + ); + EXPECT_EQ( + indexed({.type = OperationErrorType::SftpError, .sftpErrorCode = 7}).reason, + UploadFailureReason::ConnectionProblem + ); + EXPECT_EQ(indexed({.type = OperationErrorType::ChannelClosed}).reason, UploadFailureReason::ConnectionProblem); + EXPECT_EQ(indexed({.type = OperationErrorType::FutureTimeout}).reason, UploadFailureReason::ConnectionProblem); + + const auto refused = indexed({.type = OperationErrorType::SftpError, .sftpErrorCode = 4}); + EXPECT_EQ(refused.reason, UploadFailureReason::ServerRefused); + EXPECT_EQ(refused.detail, "Failure"); + + const auto unknown = indexed({.type = OperationErrorType::SizeMismatch}); + EXPECT_EQ(unknown.reason, UploadFailureReason::Unknown); + EXPECT_EQ(unknown.detail, "SizeMismatch"); + } + + TEST(FailureReasonTests, AFailedUploadTellsWhyAndOneWithoutAnErrorIsUnknown) + { + const auto failed = uploadEndOf( + completedAs(OperationCompletionReason::Failed, sftpFailure(3)), local("/home/user/a.txt"), false + ); + EXPECT_EQ(failed.reason, OperationCompletionReason::Failed); + EXPECT_FALSE(failed.changedOnServer); + ASSERT_TRUE(failed.failure.has_value()); + EXPECT_EQ(failed.failure->reason, UploadFailureReason::PermissionDeniedOnServer); + + const auto silent = + uploadEndOf(completedAs(OperationCompletionReason::Failed), local("/home/user/a.txt"), false); + ASSERT_TRUE(silent.failure.has_value()); + EXPECT_EQ(silent.failure->reason, UploadFailureReason::Unknown); + } + + TEST(FailureReasonTests, ADestinationChangedOnTheServerIsNoFailureAndAsksWhatIsThere) + { + const auto changed = uploadEndOf( + completedAs( + OperationCompletionReason::Canceled, + SharedData::OperationError{.type = OperationErrorType::ChangedSinceCompare} + ), + local("/home/user/a.txt"), + false + ); + EXPECT_EQ(changed.reason, OperationCompletionReason::Canceled); + EXPECT_TRUE(changed.changedOnServer); + EXPECT_FALSE(changed.failure.has_value()); + EXPECT_TRUE(asksDestinationState(changed, false)); + } + + TEST(FailureReasonTests, CompletedAndCancelledUploadsTellNoFailure) + { + const auto completed = + uploadEndOf(completedAs(OperationCompletionReason::Completed), local("/home/user/a.txt"), false); + EXPECT_EQ(completed.reason, OperationCompletionReason::Completed); + EXPECT_FALSE(completed.failure.has_value()); + EXPECT_TRUE(asksDestinationState(completed, false)); + + const auto cancelled = + uploadEndOf(completedAs(OperationCompletionReason::Canceled), local("/home/user/a.txt"), false); + EXPECT_EQ(cancelled.reason, OperationCompletionReason::Canceled); + EXPECT_FALSE(cancelled.changedOnServer); + EXPECT_FALSE(cancelled.failure.has_value()); + EXPECT_FALSE(asksDestinationState(cancelled, false)); + } + + TEST(FailureReasonTests, ACompletedDirectoryUploadTellsTheEntriesItLeftOut) + { + auto completed = completedAs(OperationCompletionReason::Completed); + completed.failedEntries = { + {"/home/user/project/locked.txt", + SharedData::OperationError{ + .type = OperationErrorType::OpenFailure, .localProblem = LocalFileProblem::Locked + }}, + {"/home/user/project/refused.txt", sftpFailure(3)}, + {"/home/user/project/changed.txt", + SharedData::OperationError{.type = OperationErrorType::ChangedSinceCompare}}, + {"project/entry", SharedData::OperationError{.type = OperationErrorType::InvalidName}}, + }; + const auto directory = local("/home/user/project"); + + const auto ended = uploadEndOf(completed, directory, true); + EXPECT_EQ(ended.reason, OperationCompletionReason::Completed); + EXPECT_FALSE(ended.failure.has_value()); + EXPECT_FALSE(asksDestinationState(ended, true)); + ASSERT_EQ(ended.failedEntries.size(), 3u); + EXPECT_EQ(ended.failedEntries[0].path, local("/home/user/project/locked.txt")); + EXPECT_EQ(ended.failedEntries[0].failure.reason, UploadFailureReason::LockedLocally); + EXPECT_EQ(ended.failedEntries[1].path, local("/home/user/project/refused.txt")); + EXPECT_EQ(ended.failedEntries[1].failure.reason, UploadFailureReason::PermissionDeniedOnServer); + // Its name is no path, it is told as the directory. + EXPECT_EQ(ended.failedEntries[2].path, directory); + EXPECT_EQ(ended.failedEntries[2].failure.reason, UploadFailureReason::Unknown); + + EXPECT_TRUE(uploadEndOf(completed, local("/home/user/project"), false).failedEntries.empty()); + } + + TEST(FailureReasonTests, EachEntryOfACompletedBulkEndsByItsIndex) + { + const std::vector sources{ + local("/home/user/sent.txt"), + local("/home/user/changed.txt"), + local("/home/user/gone.txt"), + local("/home/user/refused.txt"), + local("/home/user/locked.txt"), + }; + auto completed = completedAs(OperationCompletionReason::Completed); + completed.indexedFailures = { + {.index = 4u, .type = OperationErrorType::OpenFailure, .localProblem = LocalFileProblem::Locked}, + {.index = 1u, .type = OperationErrorType::ChangedSinceCompare}, + {.index = 2u, .type = OperationErrorType::FileNotFound}, + {.index = 3u, .type = OperationErrorType::SftpError, .sftpErrorCode = 4}, + }; + completed.failedEntries = { + {"/home/user/refused.txt", sftpFailure(4, "Read-only file system")}, + {"/home/user/locked.txt", + SharedData::OperationError{ + .type = OperationErrorType::OpenFailure, + .extraInfo = "Locked by another process", + .localProblem = LocalFileProblem::Locked, + }}, + }; + + const auto ends = entryEndsOf(completed, sources); + ASSERT_EQ(ends.size(), 5u); + EXPECT_EQ(ends[0].reason, OperationCompletionReason::Completed); + EXPECT_FALSE(ends[0].failure.has_value()); + EXPECT_TRUE(asksDestinationState(ends[0], false)); + + EXPECT_EQ(ends[1].reason, OperationCompletionReason::Failed); + EXPECT_TRUE(ends[1].changedOnServer); + EXPECT_FALSE(ends[1].failure.has_value()); + EXPECT_TRUE(asksDestinationState(ends[1], false)); + + // Its source was gone, the deletion is mirrored apart. + EXPECT_EQ(ends[2].reason, OperationCompletionReason::Canceled); + EXPECT_FALSE(ends[2].failure.has_value()); + EXPECT_FALSE(asksDestinationState(ends[2], false)); + + EXPECT_EQ(ends[3].reason, OperationCompletionReason::Failed); + ASSERT_TRUE(ends[3].failure.has_value()); + EXPECT_EQ(ends[3].failure->reason, UploadFailureReason::ServerRefused); + EXPECT_EQ(ends[3].failure->detail, "Read-only file system"); + EXPECT_FALSE(asksDestinationState(ends[3], false)); + + ASSERT_TRUE(ends[4].failure.has_value()); + EXPECT_EQ(ends[4].failure->reason, UploadFailureReason::LockedLocally); + EXPECT_EQ(ends[4].failure->detail, "Locked by another process"); + } + + TEST(FailureReasonTests, AnEntryLeftOutWithoutAnErrorOfItsOwnTellsItsIndexedFailure) + { + auto completed = completedAs(OperationCompletionReason::Completed); + completed.indexedFailures = {{.index = 0u, .type = OperationErrorType::SftpError, .sftpErrorCode = 3}}; + + const auto ends = entryEndsOf(completed, {local("/home/user/a.txt")}); + ASSERT_EQ(ends.size(), 1u); + EXPECT_EQ(ends[0].reason, OperationCompletionReason::Failed); + ASSERT_TRUE(ends[0].failure.has_value()); + EXPECT_EQ(ends[0].failure->reason, UploadFailureReason::PermissionDeniedOnServer); + } + + TEST(FailureReasonTests, EveryEntryOfAFailedOrCancelledBulkEndsLikeTheBulk) + { + const std::vector sources{local("/home/user/a.txt"), local("/home/user/b.txt")}; + const auto failed = entryEndsOf( + completedAs( + OperationCompletionReason::Failed, SharedData::OperationError{.type = OperationErrorType::ChannelClosed} + ), + sources + ); + ASSERT_EQ(failed.size(), 2u); + for (auto const& ended : failed) + { + EXPECT_EQ(ended.reason, OperationCompletionReason::Failed); + ASSERT_TRUE(ended.failure.has_value()); + EXPECT_EQ(ended.failure->reason, UploadFailureReason::ConnectionProblem); + } + + const auto cancelled = entryEndsOf(completedAs(OperationCompletionReason::Canceled), sources); + ASSERT_EQ(cancelled.size(), 2u); + for (auto const& ended : cancelled) + { + EXPECT_EQ(ended.reason, OperationCompletionReason::Canceled); + EXPECT_FALSE(ended.failure.has_value()); + } + } +} From 47d8105a5f88ecd19450b331c2bc3340bbb86acc Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:01:22 +0200 Subject: [PATCH 024/114] Turned queue progress of bulk, directory and single uploads into upload progress --- .../file_tracking/bulk_entry_progress.hpp | 95 +++++ .../file_tracking/bulk_entry_progress.cpp | 181 ++++++++++ .../test/backend/test_bulk_entry_progress.hpp | 339 ++++++++++++++++++ 3 files changed, 615 insertions(+) diff --git a/backend/include/backend/file_tracking/bulk_entry_progress.hpp b/backend/include/backend/file_tracking/bulk_entry_progress.hpp index 6f70f09b..cd3bfefa 100644 --- a/backend/include/backend/file_tracking/bulk_entry_progress.hpp +++ b/backend/include/backend/file_tracking/bulk_entry_progress.hpp @@ -1 +1,96 @@ #pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief Turns the successive progress the operation queue tells of one bulk or directory upload into the + * progress of each upload it carries, by the index of its request. An upload is told when its progress + * changed, from the moment it started on its way or ended. + */ + class BulkEntryProgress + { + public: + /** + * @brief An upload of a bulk of files. Its index is that of its request, which is its entry index in the + * bulk. + */ + struct File + { + Paths::LocalPath source; + /** @brief Its size when it was queued, until its transfer tells it. */ + std::uint64_t sizeBytes{0}; + }; + + /** + * @brief The progress of the upload at @p index. + */ + struct EntryProgress + { + std::size_t index{0}; + UploadProgress progress{}; + }; + + /** + * @brief A bulk of @p files. + */ + static BulkEntryProgress ofFiles(std::vector files); + + /** + * @brief The upload of the directory @p source to @p destination, the single upload at index 0. Its files + * and bytes are the entries below it the bulk is through with, the left out ones too, as the queue + * counts them. + */ + static BulkEntryProgress ofDirectory(Paths::LocalPath source, Paths::RemotePath destination); + + /** + * @brief The uploads whose progress changed with @p progress, the next snapshot of the bulk, by index. + */ + std::vector update(SharedData::BulkProgress const& progress); + + private: + struct Entry + { + File file; + /** @brief Seen on its way, it is done once it is no longer. */ + bool started{false}; + std::optional told{}; + }; + + struct Directory + { + Paths::LocalPath source; + Paths::RemotePath destination; + std::optional told{}; + }; + + BulkEntryProgress(std::vector entries, std::optional directory); + + std::vector updateFiles(SharedData::BulkProgress const& progress); + std::vector updateDirectory(SharedData::BulkProgress const& progress); + + private: + std::vector entries_; + std::optional directory_; + }; + + /** + * @brief How far the single upload of the file or link @p source is, its transfer between its minimum and + * maximum at @p progress. + */ + UploadProgress uploadProgressOf(SharedData::TransferProgress const& progress, Paths::LocalPath const& source); + + /** + * @brief Whether @p left and @p right tell the same. + */ + bool sameProgress(UploadProgress const& left, UploadProgress const& right); +} diff --git a/backend/source/backend/file_tracking/bulk_entry_progress.cpp b/backend/source/backend/file_tracking/bulk_entry_progress.cpp index 7e477237..d03df1ab 100644 --- a/backend/source/backend/file_tracking/bulk_entry_progress.cpp +++ b/backend/source/backend/file_tracking/bulk_entry_progress.cpp @@ -1 +1,182 @@ #include + +#include +#include +#include +#include + +namespace FileTracking +{ + namespace + { + bool sameFile(SharedData::WatchFileProgress const& left, SharedData::WatchFileProgress const& right) + { + return left.path == right.path && left.bytes == right.bytes && left.totalBytes == right.totalBytes; + } + + UploadProgress onItsWay(Paths::LocalPath const& source, std::uint64_t bytes, std::uint64_t totalBytes) + { + const auto sent = std::min(bytes, totalBytes); + return { + .bytesSent = sent, + .bytesTotal = totalBytes, + .inFlight = {{.path = source, .bytes = sent, .totalBytes = totalBytes}}, + .filesDone = 0u, + .fileCount = 1u, + }; + } + + UploadProgress done(std::uint64_t totalBytes) + { + return { + .bytesSent = totalBytes, + .bytesTotal = totalBytes, + .filesDone = 1u, + .fileCount = 1u, + .transferred = true, + }; + } + + UploadProgress leftOut(std::uint64_t sent, std::uint64_t totalBytes) + { + return {.bytesSent = sent, .bytesTotal = totalBytes, .filesDone = 0u, .fileCount = 1u}; + } + + /** + * @brief Remembers @p now as @p told and adds it to @p changed unless it was told already. + */ + void tellChanged( + std::optional& told, + std::size_t index, + UploadProgress now, + std::vector& changed + ) + { + if (told && sameProgress(*told, now)) + return; + told = now; + changed.push_back({.index = index, .progress = std::move(now)}); + } + } + + BulkEntryProgress::BulkEntryProgress(std::vector entries, std::optional directory) + : entries_{std::move(entries)} + , directory_{std::move(directory)} + {} + + BulkEntryProgress BulkEntryProgress::ofFiles(std::vector files) + { + std::vector entries; + entries.reserve(files.size()); + for (auto& file : files) + entries.push_back({.file = std::move(file)}); + return BulkEntryProgress{std::move(entries), std::nullopt}; + } + + BulkEntryProgress BulkEntryProgress::ofDirectory(Paths::LocalPath source, Paths::RemotePath destination) + { + return BulkEntryProgress{{}, Directory{.source = std::move(source), .destination = std::move(destination)}}; + } + + std::vector BulkEntryProgress::update(SharedData::BulkProgress const& progress) + { + if (directory_) + return updateDirectory(progress); + return updateFiles(progress); + } + + std::vector + BulkEntryProgress::updateFiles(SharedData::BulkProgress const& progress) + { + const std::set leftOutIndices( + progress.failedEntryIndices.begin(), progress.failedEntryIndices.end() + ); + std::map onTheirWay; + for (auto const& file : progress.inFlight) + onTheirWay.emplace(file.entryIndex, &file); + // Every entry ended, those that were never seen on their way too. + const bool through = progress.fileCount > 0u && progress.fileCurrentIndex >= progress.fileCount; + + std::vector changed; + for (std::size_t index = 0; index < entries_.size(); ++index) + { + auto& entry = entries_[index]; + if (const auto found = onTheirWay.find(index); found != onTheirWay.end()) + { + entry.started = true; + entry.file.sizeBytes = found->second->totalBytes; + tellChanged( + entry.told, index, onItsWay(entry.file.source, found->second->bytes, entry.file.sizeBytes), changed + ); + } + else if (leftOutIndices.contains(index)) + tellChanged( + entry.told, index, leftOut(entry.told ? entry.told->bytesSent : 0u, entry.file.sizeBytes), changed + ); + else if (entry.started || through) + tellChanged(entry.told, index, done(entry.file.sizeBytes), changed); + } + return changed; + } + + std::vector + BulkEntryProgress::updateDirectory(SharedData::BulkProgress const& progress) + { + auto& directory = *directory_; + UploadProgress now{ + .bytesSent = progress.bytesCurrent, + .bytesTotal = progress.bytesTotal, + .filesDone = progress.fileCurrentIndex, + .fileCount = progress.fileCount, + }; + for (auto const& file : progress.inFlight) + { + const auto local = Paths::RemotePath::make(file.file) + .and_then( + [&directory](Paths::RemotePath const& remote) + { + return remote.relativeTo(directory.destination); + } + ) + .and_then( + [&directory](Paths::RelativePath const& relative) + { + return directory.source.join(relative); + } + ); + if (local) + now.inFlight.push_back({.path = *local, .bytes = file.bytes, .totalBytes = file.totalBytes}); + } + now.transferred = + progress.fileCount > 0u && progress.fileCurrentIndex >= progress.fileCount && progress.inFlight.empty(); + + std::vector changed; + tellChanged(directory.told, 0u, std::move(now), changed); + return changed; + } + + UploadProgress uploadProgressOf(SharedData::TransferProgress const& progress, Paths::LocalPath const& source) + { + const auto total = progress.max > progress.min ? progress.max - progress.min : 0u; + const auto position = progress.current > progress.min ? progress.current - progress.min : 0u; + const auto sent = std::min(position, total); + const bool transferred = progress.current >= progress.max; + UploadProgress result{ + .bytesSent = sent, + .bytesTotal = total, + .filesDone = transferred ? 1u : 0u, + .fileCount = 1u, + .transferred = transferred, + }; + if (!transferred) + result.inFlight.push_back({.path = source, .bytes = sent, .totalBytes = total}); + return result; + } + + bool sameProgress(UploadProgress const& left, UploadProgress const& right) + { + return left.bytesSent == right.bytesSent && left.bytesTotal == right.bytesTotal && + left.filesDone == right.filesDone && left.fileCount == right.fileCount && + left.transferred == right.transferred && std::ranges::equal(left.inFlight, right.inFlight, sameFile); + } +} diff --git a/backend/test/backend/test_bulk_entry_progress.hpp b/backend/test/backend/test_bulk_entry_progress.hpp index 6f70f09b..881d9d83 100644 --- a/backend/test/backend/test_bulk_entry_progress.hpp +++ b/backend/test/backend/test_bulk_entry_progress.hpp @@ -1 +1,340 @@ #pragma once + +#include +#include +#include "real_server_tests.hpp" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern std::filesystem::path temporaryRoot; + +namespace Test::BulkEntryProgressTest +{ + using FileTracking::BulkEntryProgress; + using FileTracking::UploadProgress; + + namespace BulkEntryProgressTestDetail + { + inline Paths::LocalPath local(std::string_view path) + { + return Paths::LocalPath::fromUtf8(path, Paths::LocalPlatform::Posix).value(); + } + + inline SharedData::BulkFileProgress + travelling(std::uint64_t entryIndex, std::uint64_t bytes, std::uint64_t totalBytes, std::string file = "/srv/f") + { + return {.file = std::move(file), .entryIndex = entryIndex, .bytes = bytes, .totalBytes = totalBytes}; + } + + inline SharedData::BulkProgress snapshot( + std::uint64_t finished, + std::uint64_t count, + std::vector inFlight, + std::vector leftOut = {} + ) + { + return { + .operationId = Ids::generateOperationId(), + .fileCurrentIndex = finished, + .fileCount = count, + .bytesCurrent = 0u, + .bytesTotal = 0u, + .bytesPerSecond = 0, + .inFlight = std::move(inFlight), + .failedEntryIndices = std::move(leftOut), + }; + } + + inline BulkEntryProgress twoFiles() + { + return BulkEntryProgress::ofFiles({ + {.source = local("/home/user/a.txt"), .sizeBytes = 10u}, + {.source = local("/home/user/b.txt"), .sizeBytes = 20u}, + }); + } + + /** + * @brief What was told, the last progress of each index. + */ + inline std::map byIndex(std::vector const& told) + { + std::map last; + for (auto const& entry : told) + last.insert_or_assign(entry.index, entry.progress); + return last; + } + } + + using namespace BulkEntryProgressTestDetail; + + TEST(BulkEntryProgressTests, AFileOnItsWayTellsItsBytesUnderItsLocalPathAndTheOthersWait) + { + auto progress = twoFiles(); + + const auto told = progress.update(snapshot(0u, 2u, {travelling(0u, 4u, 10u)})); + + ASSERT_EQ(told.size(), 1u); + EXPECT_EQ(told[0].index, 0u); + EXPECT_EQ(told[0].progress.bytesSent, 4u); + EXPECT_EQ(told[0].progress.bytesTotal, 10u); + EXPECT_EQ(told[0].progress.filesDone, 0u); + EXPECT_EQ(told[0].progress.fileCount, 1u); + EXPECT_FALSE(told[0].progress.transferred); + ASSERT_EQ(told[0].progress.inFlight.size(), 1u); + EXPECT_EQ(told[0].progress.inFlight[0].path, local("/home/user/a.txt")); + EXPECT_EQ(told[0].progress.inFlight[0].bytes, 4u); + EXPECT_EQ(told[0].progress.inFlight[0].totalBytes, 10u); + } + + TEST(BulkEntryProgressTests, AFileNoLongerOnItsWayIsDoneAndNothingIsToldTwice) + { + auto progress = twoFiles(); + std::ignore = progress.update(snapshot(0u, 2u, {travelling(0u, 4u, 10u)})); + + const auto told = byIndex(progress.update(snapshot(1u, 2u, {travelling(1u, 5u, 20u)}))); + + ASSERT_EQ(told.size(), 2u); + EXPECT_TRUE(told.at(0).transferred); + EXPECT_EQ(told.at(0).bytesSent, 10u); + EXPECT_EQ(told.at(0).filesDone, 1u); + EXPECT_TRUE(told.at(0).inFlight.empty()); + EXPECT_EQ(told.at(1).bytesSent, 5u); + EXPECT_FALSE(told.at(1).transferred); + + EXPECT_TRUE(progress.update(snapshot(1u, 2u, {travelling(1u, 5u, 20u)})).empty()); + } + + TEST(BulkEntryProgressTests, AFileLeftOutKeepsWhatWentAndIsNotDone) + { + auto progress = twoFiles(); + std::ignore = progress.update(snapshot(0u, 2u, {travelling(0u, 3u, 10u)})); + + const auto told = progress.update(snapshot(1u, 2u, {}, {0u})); + + ASSERT_EQ(told.size(), 1u); + EXPECT_EQ(told[0].index, 0u); + EXPECT_FALSE(told[0].progress.transferred); + EXPECT_EQ(told[0].progress.bytesSent, 3u); + EXPECT_EQ(told[0].progress.filesDone, 0u); + EXPECT_TRUE(told[0].progress.inFlight.empty()); + } + + TEST(BulkEntryProgressTests, FilesNeverSeenOnTheirWayAreDoneOnceTheBulkIsThroughExceptThoseLeftOut) + { + auto progress = twoFiles(); + EXPECT_TRUE(progress.update(snapshot(0u, 2u, {})).empty()); + + const auto told = byIndex(progress.update(snapshot(2u, 2u, {}, {1u}))); + + ASSERT_EQ(told.size(), 2u); + EXPECT_TRUE(told.at(0).transferred); + EXPECT_EQ(told.at(0).bytesSent, 10u); + EXPECT_FALSE(told.at(1).transferred); + EXPECT_EQ(told.at(1).bytesSent, 0u); + EXPECT_EQ(told.at(1).bytesTotal, 20u); + } + + TEST(BulkEntryProgressTests, TheSizeTheTransferTellsReplacesTheQueuedOne) + { + auto progress = twoFiles(); + std::ignore = progress.update(snapshot(0u, 2u, {travelling(0u, 12u, 12u)})); + + const auto told = byIndex(progress.update(snapshot(1u, 2u, {}))); + + ASSERT_TRUE(told.contains(0)); + EXPECT_EQ(told.at(0).bytesSent, 12u); + EXPECT_EQ(told.at(0).bytesTotal, 12u); + } + + TEST(BulkEntryProgressTests, EntriesBeyondTheRequestsAreIgnored) + { + auto progress = twoFiles(); + + EXPECT_TRUE(progress.update(snapshot(0u, 2u, {travelling(5u, 1u, 2u)}, {7u})).empty()); + } + + TEST(BulkEntryProgressTests, ADirectoryTellsItsEntriesAndItsFilesOnTheirWayByLocalPath) + { + auto progress = BulkEntryProgress::ofDirectory(local("/home/user/project"), remote("/srv/project")); + auto running = snapshot( + 1u, + 3u, + {travelling(2u, 3u, 10u, "/srv/project/src/main.cpp"), travelling(3u, 1u, 4u, "/elsewhere/other.txt")} + ); + running.bytesCurrent = 5u; + running.bytesTotal = 30u; + + const auto told = progress.update(running); + + ASSERT_EQ(told.size(), 1u); + EXPECT_EQ(told[0].index, 0u); + EXPECT_EQ(told[0].progress.bytesSent, 5u); + EXPECT_EQ(told[0].progress.bytesTotal, 30u); + EXPECT_EQ(told[0].progress.filesDone, 1u); + EXPECT_EQ(told[0].progress.fileCount, 3u); + EXPECT_FALSE(told[0].progress.transferred); + ASSERT_EQ(told[0].progress.inFlight.size(), 1u); + EXPECT_EQ(told[0].progress.inFlight[0].path, local("/home/user/project/src/main.cpp")); + EXPECT_EQ(told[0].progress.inFlight[0].bytes, 3u); + EXPECT_EQ(told[0].progress.inFlight[0].totalBytes, 10u); + + EXPECT_TRUE(progress.update(running).empty()); + + auto through = snapshot(3u, 3u, {}); + through.bytesCurrent = 30u; + through.bytesTotal = 30u; + const auto ended = progress.update(through); + ASSERT_EQ(ended.size(), 1u); + EXPECT_TRUE(ended[0].progress.transferred); + EXPECT_TRUE(ended[0].progress.inFlight.empty()); + EXPECT_EQ(ended[0].progress.bytesSent, 30u); + } + + TEST(BulkEntryProgressTests, ADirectoryNotScannedYetIsNotTransferred) + { + auto progress = BulkEntryProgress::ofDirectory(local("/home/user/project"), remote("/srv/project")); + + const auto told = progress.update(snapshot(0u, 0u, {})); + + ASSERT_EQ(told.size(), 1u); + EXPECT_FALSE(told[0].progress.transferred); + } + + TEST(BulkEntryProgressTests, ASingleTransferTellsItsPositionBetweenItsBounds) + { + const auto source = local("/home/user/a.txt"); + const auto running = FileTracking::uploadProgressOf({.min = 0u, .max = 100u, .current = 40u}, source); + EXPECT_EQ(running.bytesSent, 40u); + EXPECT_EQ(running.bytesTotal, 100u); + EXPECT_EQ(running.filesDone, 0u); + EXPECT_EQ(running.fileCount, 1u); + EXPECT_FALSE(running.transferred); + ASSERT_EQ(running.inFlight.size(), 1u); + EXPECT_EQ(running.inFlight[0].path, source); + EXPECT_EQ(running.inFlight[0].bytes, 40u); + EXPECT_EQ(running.inFlight[0].totalBytes, 100u); + + const auto sent = FileTracking::uploadProgressOf({.min = 0u, .max = 100u, .current = 100u}, source); + EXPECT_TRUE(sent.transferred); + EXPECT_EQ(sent.bytesSent, 100u); + EXPECT_EQ(sent.filesDone, 1u); + EXPECT_TRUE(sent.inFlight.empty()); + + // A link or a vanished source shows as done without bytes. + const auto link = FileTracking::uploadProgressOf({.min = 1u, .max = 1u, .current = 1u}, source); + EXPECT_TRUE(link.transferred); + EXPECT_EQ(link.bytesSent, 0u); + EXPECT_EQ(link.bytesTotal, 0u); + } + + class BulkEntryProgressServerTests : public RealServerTests + { + protected: + static void writeFile(std::filesystem::path const& path, std::string_view contents) + { + std::ofstream stream{path, std::ios::binary}; + stream << contents; + } + + Utility::TemporaryDirectory sources_{temporaryRoot, true}; + }; + + TEST_F(BulkEntryProgressServerTests, TheEntryIndexOfABulkOfFilesIsTheIndexOfItsRequest) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + + // Not in name order, the third source is gone by the time its turn comes. + const std::vector> requests{ + {"zeta.txt", "zeta content"}, + {"alpha.txt", "alpha"}, + {"gone.txt", ""}, + {"mid.txt", "the middle one"}, + }; + std::vector files; + std::vector uploads; + std::vector destinations; + for (auto const& [name, content] : requests) + { + if (name != "gone.txt") + writeFile(sources_.path() / name, content); + const auto destination = "/home/test/entry_index_" + name; + files.push_back({ + .source = toLocal(sources_.path() / name), + .destination = remote(destination), + .sizeBytes = content.size(), + .mayOverwrite = true, + }); + uploads.push_back({.source = toLocal(sources_.path() / name), .sizeBytes = content.size()}); + destinations.push_back(destination); + } + + auto entryProgress = BulkEntryProgress::ofFiles(uploads); + std::vector snapshots; + std::map told; + UploadOperation::UploadOperationOptions individualOptions{}; + individualOptions.sourceMayVanish = true; + individualOptions.createMissingDirectories = true; + BulkUploadOperation operation{ + sftp, + { + .overallProgressCallback = + [&](SharedData::BulkProgress const& update) + { + snapshots.push_back(update); + for (auto const& entry : entryProgress.update(update)) + told.insert_or_assign(entry.index, entry.progress); + }, + .remotePath = remote("/home/test"), + .localPath = toLocal(sources_.path()), + .individualOptions = std::move(individualOptions), + .concurrency = 2, + .progressEmitInterval = std::chrono::milliseconds{0}, + } + }; + operation.setPrescannedFileList(std::move(files)); + + for (int step = 0; step < 2000; ++step) + { + const auto worked = operation.work(); + ASSERT_TRUE(worked.has_value()) << worked.error().toString(); + if (*worked == BulkUploadOperation::WorkStatus::Complete) + break; + } + + std::set seen; + for (auto const& update : snapshots) + for (auto const& file : update.inFlight) + { + ASSERT_LT(file.entryIndex, destinations.size()); + EXPECT_EQ(file.file, destinations[file.entryIndex]); + seen.insert(file.entryIndex); + } + EXPECT_EQ(seen, (std::set{0u, 1u, 2u, 3u})); + ASSERT_FALSE(snapshots.empty()); + EXPECT_EQ(snapshots.back().failedEntryIndices, std::vector{2u}); + ASSERT_EQ(operation.indexedFailures().size(), 1u); + EXPECT_EQ(operation.indexedFailures().front().index, 2u); + + ASSERT_EQ(told.size(), 4u); + for (std::size_t index : {0u, 1u, 3u}) + { + EXPECT_TRUE(told.at(index).transferred) << requests[index].first; + EXPECT_EQ(told.at(index).bytesSent, requests[index].second.size()) << requests[index].first; + } + EXPECT_FALSE(told.at(2u).transferred); + } +} From 8b1ecfa6ba2c2b02bee4c5c406081e0e6f2dccc6 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:03:02 +0200 Subject: [PATCH 025/114] Ended refused uploads as not queued, or cancelled when their source is gone --- .../backend/file_tracking/batching_remote.cpp | 23 ++- backend/test/backend/test_batching_remote.hpp | 139 +++++++++++++++++- 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/backend/source/backend/file_tracking/batching_remote.cpp b/backend/source/backend/file_tracking/batching_remote.cpp index 5f27dd4b..df62a070 100644 --- a/backend/source/backend/file_tracking/batching_remote.cpp +++ b/backend/source/backend/file_tracking/batching_remote.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -71,7 +72,21 @@ namespace FileTracking }; } - const UploadEnd failedUpload{.reason = SharedData::OperationCompletionReason::Failed}; + /** + * @brief How an upload the inner remote refused with @p error ends: as cancelled when its source is gone, + * whose deletion is mirrored apart, otherwise as failed for not being queued. + */ + UploadEnd refusedUpload(UploadRequest const& request, std::string const& error) + { + std::error_code statusError; + if (std::filesystem::symlink_status(request.source.native(), statusError).type() == + std::filesystem::file_type::not_found) + return {.reason = SharedData::OperationCompletionReason::Canceled}; + return { + .reason = SharedData::OperationCompletionReason::Failed, + .failure = UploadFailureInfo{.reason = SharedData::UploadFailureReason::NotQueued, .detail = error}, + }; + } } struct BatchingRemote::Implementation : public std::enable_shared_from_this @@ -166,7 +181,9 @@ namespace FileTracking !queued) { Log::warn("FileTracking: did not upload {} changed files: {}", bulk.size(), queued.error()); - endAll(bulkEnds, failedUpload); + for (std::size_t index = 0; index < bulk.size(); ++index) + if (bulkEnds[index]) + bulkEnds[index](refusedUpload(bulk[index], queued.error())); } } for (auto& upload : single) @@ -177,7 +194,7 @@ namespace FileTracking // A temporary file an editor already renamed or removed ends up here. Log::warn("FileTracking: did not upload '{}': {}", upload.request.source.toUtf8(), queued.error()); if (upload.onEnded) - upload.onEnded(failedUpload); + upload.onEnded(refusedUpload(upload.request, queued.error())); } } } diff --git a/backend/test/backend/test_batching_remote.hpp b/backend/test/backend/test_batching_remote.hpp index 58835712..5c788830 100644 --- a/backend/test/backend/test_batching_remote.hpp +++ b/backend/test/backend/test_batching_remote.hpp @@ -172,6 +172,10 @@ namespace Test std::map ended_{}; /** @brief What the server had at the destination of each upload of the test that ended, by its name. */ std::map> destinationStates_{}; + /** @brief Why each upload of the test that ended failed, by its name. */ + std::map> failures_{}; + /** @brief The progress each upload of the test heard, by its name. */ + std::map> progress_{}; void SetUp() override { @@ -225,6 +229,15 @@ namespace Test { ended_[name] = ended.reason; destinationStates_[name] = ended.destinationState; + failures_[name] = ended.failure; + }; + } + + FileTracking::TrackerRemote::OnUploadProgress recordProgressAs(std::string name) + { + return [this, name = std::move(name)](FileTracking::UploadProgress const& progress) + { + progress_[name].push_back(progress); }; } @@ -238,7 +251,7 @@ namespace Test .createMissingDirectories = createMissingDirectories, }, recordUploadAs(name), - {} + recordProgressAs(name) ) .has_value()); } @@ -427,6 +440,50 @@ namespace Test )); EXPECT_EQ(ended_.at("a.txt"), SharedData::OperationCompletionReason::Failed); EXPECT_EQ(ended_.at("b.txt"), SharedData::OperationCompletionReason::Failed); + for (auto const* name : {"a.txt", "b.txt"}) + { + ASSERT_TRUE(failures_.at(name).has_value()) << name; + EXPECT_EQ(failures_.at(name)->reason, SharedData::UploadFailureReason::NotQueued) << name; + EXPECT_EQ(failures_.at(name)->detail, "refused") << name; + } + } + + TEST_F(BatchingRemoteTest, AnUploadTheQueueRefusesEndsFailedAsNotQueuedAndTellsWhy) + { + inner_->failQueueing = true; + upload("a.txt"); + + ASSERT_TRUE(runUntil( + [this]() + { + return ended_.contains("a.txt"); + } + )); + EXPECT_EQ(ended_.at("a.txt"), SharedData::OperationCompletionReason::Failed); + ASSERT_TRUE(failures_.at("a.txt").has_value()); + EXPECT_EQ(failures_.at("a.txt")->reason, SharedData::UploadFailureReason::NotQueued); + EXPECT_EQ(failures_.at("a.txt")->detail, "refused"); + EXPECT_FALSE(progress_.contains("a.txt")); + } + + TEST_F(BatchingRemoteTest, ARefusedUploadWhoseSourceIsGoneEndsCanceledWithoutAFailure) + { + inner_->failQueueing = true; + upload("a.txt"); + upload("b.txt"); + std::filesystem::remove(root_ / "a.txt"); + + ASSERT_TRUE(runUntil( + [this]() + { + return ended_.size() == 2u; + } + )); + EXPECT_EQ(ended_.at("a.txt"), SharedData::OperationCompletionReason::Canceled); + EXPECT_FALSE(failures_.at("a.txt").has_value()); + EXPECT_EQ(ended_.at("b.txt"), SharedData::OperationCompletionReason::Failed); + ASSERT_TRUE(failures_.at("b.txt").has_value()); + EXPECT_EQ(failures_.at("b.txt")->reason, SharedData::UploadFailureReason::NotQueued); } TEST_F(BatchingRemoteTest, EverythingElsePassesThroughAtOnce) @@ -523,6 +580,86 @@ namespace Test EXPECT_EQ(inner_->of("upload")[1]->paths.front(), "/srv/renamed.txt"); } + TEST_F(BatchingRemoteTest, AnUploadSentOnItsOwnHearsWhatTheInnerRemoteTellsOfIt) + { + upload("a.txt"); + ASSERT_TRUE(runUntil( + [this]() + { + return !inner_->queued.empty(); + } + )); + ASSERT_EQ(inner_->of("upload").size(), 1u); + auto const* sent = inner_->of("upload").front(); + ASSERT_TRUE(sent->onUploadProgress); + + sent->onUploadProgress({.bytesSent = 3u, .bytesTotal = 5u, .filesDone = 0u, .fileCount = 1u}); + + ASSERT_EQ(progress_["a.txt"].size(), 1u); + EXPECT_EQ(progress_["a.txt"].front().bytesSent, 3u); + EXPECT_EQ(progress_["a.txt"].front().bytesTotal, 5u); + } + + TEST_F(BatchingRemoteTest, EachUploadOfABulkHearsTheProgressAtTheIndexOfItsRequest) + { + upload("a.txt"); + upload("b.txt"); + upload("c.txt"); + ASSERT_TRUE(runUntil( + [this]() + { + return !inner_->queued.empty(); + } + )); + const auto bulks = inner_->of("uploadAll"); + ASSERT_EQ(bulks.size(), 1u); + ASSERT_EQ(bulks.front()->paths, (std::vector{"/srv/a.txt", "/srv/b.txt", "/srv/c.txt"})); + ASSERT_TRUE(bulks.front()->onUploadEntryProgress); + + bulks.front()->onUploadEntryProgress(2, {.bytesSent = 2u, .bytesTotal = 5u}); + bulks.front()->onUploadEntryProgress(0, {.bytesSent = 1u, .bytesTotal = 5u}); + bulks.front()->onUploadEntryProgress(2, {.bytesSent = 5u, .bytesTotal = 5u, .transferred = true}); + bulks.front()->onUploadEntryProgress(3, {.bytesSent = 9u, .bytesTotal = 9u}); + + ASSERT_EQ(progress_["a.txt"].size(), 1u); + EXPECT_EQ(progress_["a.txt"].front().bytesSent, 1u); + EXPECT_FALSE(progress_.contains("b.txt")); + ASSERT_EQ(progress_["c.txt"].size(), 2u); + EXPECT_EQ(progress_["c.txt"][0].bytesSent, 2u); + EXPECT_TRUE(progress_["c.txt"][1].transferred); + } + + TEST_F(BatchingRemoteTest, AGatheredUploadStoppedBeforeItsBatchGoesHearsNothingMore) + { + upload("a.txt"); + upload("b.txt"); + upload("c.txt"); + remote_->cancelUpload(remote("/srv/b.txt"), FileTracking::PartialFile::Kept); + ASSERT_TRUE(runUntil( + [this]() + { + return !inner_->queued.empty(); + } + )); + const auto bulks = inner_->of("uploadAll"); + ASSERT_EQ(bulks.size(), 1u); + ASSERT_EQ(bulks.front()->paths, (std::vector{"/srv/a.txt", "/srv/c.txt"})); + + bulks.front()->onUploadEntryProgress(0, {.bytesSent = 1u, .bytesTotal = 5u}); + bulks.front()->onUploadEntryProgress(1, {.bytesSent = 4u, .bytesTotal = 5u}); + bulks.front()->onUploadEntryEnded(0, {.reason = SharedData::OperationCompletionReason::Completed}); + bulks.front()->onUploadEntryEnded(1, {.reason = SharedData::OperationCompletionReason::Completed}); + + EXPECT_EQ(ended_.at("b.txt"), SharedData::OperationCompletionReason::Canceled); + EXPECT_FALSE(progress_.contains("b.txt")); + ASSERT_EQ(progress_["a.txt"].size(), 1u); + EXPECT_EQ(progress_["a.txt"].front().bytesSent, 1u); + ASSERT_EQ(progress_["c.txt"].size(), 1u); + EXPECT_EQ(progress_["c.txt"].front().bytesSent, 4u); + EXPECT_EQ(ended_.at("a.txt"), SharedData::OperationCompletionReason::Completed); + EXPECT_EQ(ended_.at("c.txt"), SharedData::OperationCompletionReason::Completed); + } + TEST_F(BatchingRemoteTest, NothingGatheredIsSentOnceTheRemoteIsGone) { upload("a.txt"); From e7787bc18904a19cc4a83049cc83f1fb89755f17 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:05:35 +0200 Subject: [PATCH 026/114] Told the tracker how far uploads are, why they failed and what the server has --- .../source/backend/session_file_tracking.cpp | 252 ++++++++++++------ 1 file changed, 164 insertions(+), 88 deletions(-) diff --git a/backend/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index e5eb082b..e2d832df 100644 --- a/backend/source/backend/session_file_tracking.cpp +++ b/backend/source/backend/session_file_tracking.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include #include #include @@ -10,6 +12,7 @@ #include #include #include +#include #include #include @@ -29,6 +32,8 @@ #include #include #include +#include +#include namespace { @@ -152,6 +157,94 @@ namespace return std::filesystem::is_directory(std::filesystem::symlink_status(path.native(), statusError)); } + /** + * @brief The progress observer and the completion callback of one queued operation, the observer stays silent + * once the operation completed. + */ + struct ProgressUntilEnd + { + OperationQueue::ProgressObserver observer; + std::function onCompleted; + }; + + /** + * @brief Hands the progress of an operation to @p observer until @p onCompleted hears it completed, both on the + * strand. The observer is empty for an empty @p observer. + */ + ProgressUntilEnd progressUntilEnd( + OperationQueue::ProgressObserver observer, + std::function onCompleted + ) + { + const auto ended = std::make_shared(false); + ProgressUntilEnd result{ + .observer = {}, + .onCompleted = + [ended, onCompleted = std::move(onCompleted)](SharedData::OperationCompleted const& completed) + { + *ended = true; + if (onCompleted) + onCompleted(completed); + }, + }; + if (observer) + result.observer = [ended, observer = std::move(observer)](OperationQueue::OperationProgress const& progress) + { + if (!*ended) + observer(progress); + }; + return result; + } + + /** + * @brief Tells @p onProgress how far the single upload of @p request is, that of a directory by its entries. + */ + OperationQueue::ProgressObserver singleUploadProgress( + FileTracking::UploadRequest const& request, + FileTracking::TrackerRemote::OnUploadProgress onProgress + ) + { + const auto directory = std::make_shared( + FileTracking::BulkEntryProgress::ofDirectory(request.source, request.destination) + ); + return [source = request.source, + directory, + onProgress = std::move(onProgress)](OperationQueue::OperationProgress const& progress) + { + Utility::visitOverloaded( + progress, + [&source, &onProgress](SharedData::TransferProgress const& transfer) + { + onProgress(FileTracking::uploadProgressOf(transfer, source)); + }, + [&directory, &onProgress](SharedData::BulkProgress const& bulk) + { + for (auto const& entry : directory->update(bulk)) + onProgress(entry.progress); + } + ); + }; + } + + /** + * @brief Tells @p onProgress how far each upload of the bulk of @p files is, by the index of its request. + */ + OperationQueue::ProgressObserver bulkUploadProgress( + std::vector files, + FileTracking::BulkTrackerRemote::OnUploadEntryProgress onProgress + ) + { + const auto entries = std::make_shared( + FileTracking::BulkEntryProgress::ofFiles(std::move(files)) + ); + return [entries, onProgress = std::move(onProgress)](OperationQueue::OperationProgress const& progress) + { + if (auto const* bulk = std::get_if(&progress)) + for (auto const& entry : entries->update(*bulk)) + onProgress(entry.index, entry.progress); + }; + } + /** * @brief Gathers the files an upload sent unchanged, told on the strands of its transfers, until it ended. */ @@ -231,17 +324,20 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote ); } - std::expected upload( - FileTracking::UploadRequest const& request, - OnUploadEnded onEnded, - [[maybe_unused]] OnUploadProgress onProgress - ) override + std::expected + upload(FileTracking::UploadRequest const& request, OnUploadEnded onEnded, OnUploadProgress onProgress) override { const bool isDirectory = isDirectoryLocally(request.source); const auto sent = std::make_shared(); + auto progressing = progressUntilEnd( + onProgress ? singleUploadProgress(request, std::move(onProgress)) : OperationQueue::ProgressObserver{}, + forgettingSentUpload( + request.destination, endOfUpload(session_, request, isDirectory, sent, std::move(onEnded)) + ) + ); std::optional queuedAs{}; auto queued = queue( - [&request, &sent, &queuedAs]( + [&request, &sent, &queuedAs, &observer = progressing.observer]( OperationQueue& queue, SecureShell::SftpSession& channel, Ids::OperationId const& operationId ) { @@ -264,13 +360,13 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote if (added) { queue.leaveChangedDestinationsToOwner(operationId); + if (observer) + queue.whenProgressing(operationId, std::move(observer)); queuedAs = operationId; } return added; }, - forgettingSentUpload( - request.destination, endOfUpload(session_, request.destination, isDirectory, sent, std::move(onEnded)) - ) + std::move(progressing.onCompleted) ); if (queued && queuedAs) sentUploads_->insert_or_assign(request.destination.value(), *queuedAs); @@ -354,30 +450,36 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote std::expected uploadAll( std::vector const& requests, OnUploadEntryEnded onEnded, - [[maybe_unused]] OnUploadEntryProgress onProgress + OnUploadEntryProgress onProgress ) override { return withChannel( [&requests, - &onEnded](Session& session, SecureShell::SftpSession& channel) -> std::expected + &onEnded, + &onProgress](Session& session, SecureShell::SftpSession& channel) -> std::expected { const auto sent = std::make_shared(); SharedData::BulkUploadRequest bulk{ .allowOverwrite = true, .mode = SharedData::OperationMode::PriorityQueued, }; + // Entry indices are request indices, the progress and the ends rely on it. + std::vector files; bulk.entries.reserve(requests.size()); + files.reserve(requests.size()); for (auto const& request : requests) { std::error_code sizeError; - const auto sizeBytes = std::filesystem::file_size(request.source.native(), sizeError); + const auto size = std::filesystem::file_size(request.source.native(), sizeError); + const auto sizeBytes = sizeError ? 0u : static_cast(size); bulk.entries.push_back({ .source = request.source, .destination = request.destination, - .sizeBytes = sizeError ? 0u : static_cast(sizeBytes), + .sizeBytes = sizeBytes, .mayOverwrite = true, .comparedDestination = comparedStateOf(request.expectedDestination), }); + files.push_back({.source = request.source, .sizeBytes = sizeBytes}); } auto& queue = *session.operationQueue_; const auto cardId = Ids::generateOperationId(); @@ -395,8 +497,9 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote // The tracker decides anew what goes once the session is back. queue.discardResumeBackup(cardId); queue.leaveChangedDestinationsToOwner(cardId); - queue.whenCompleted( - cardId, + auto progressing = progressUntilEnd( + onProgress ? bulkUploadProgress(std::move(files), std::move(onProgress)) + : OperationQueue::ProgressObserver{}, [session = session.weak_from_this(), onEnded = std::move(onEnded), sent, @@ -408,6 +511,9 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote endUploads(session, completed, sources, destinations, sent->take(), onEnded); } ); + if (progressing.observer) + queue.whenProgressing(cardId, std::move(progressing.observer)); + queue.whenCompleted(cardId, std::move(progressing.onCompleted)); session.resetQueueThrottle(); return {}; } @@ -457,12 +563,13 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote private: /** - * @brief Hands the end of an upload to @p onEnded, a completed one of anything but a directory once the server - * told what it has at @p destination, with the files @p sent tells of. Nothing for an empty @p onEnded. + * @brief Hands the end of the upload of @p request to @p onEnded, with the files @p sent tells of. A completed + * one of anything but a directory, and one whose destination changed on the server, once the server told + * what it has there. Nothing for an empty @p onEnded. */ static std::function endOfUpload( std::weak_ptr session, - Paths::RemotePath destination, + FileTracking::UploadRequest const& request, bool isDirectory, std::shared_ptr sent, OnUploadEnded onEnded @@ -471,37 +578,34 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote if (!onEnded) return {}; return [session = std::move(session), - destination = std::move(destination), + source = request.source, + destination = request.destination, isDirectory, sent = std::move(sent), onEnded = std::move(onEnded)](SharedData::OperationCompleted const& completed) { - const bool uploaded = completed.reason == SharedData::OperationCompletionReason::Completed; - if (!uploaded || isDirectory) - return onEnded({ - .reason = completed.reason, - .changedOnServer = - completed.error && completed.error->type == SharedData::OperationErrorType::ChangedSinceCompare, - .sentFiles = uploaded ? sent->take() : std::vector{}, - }); + auto ended = FileTracking::uploadEndOf(completed, source, isDirectory); + if (ended.reason == SharedData::OperationCompletionReason::Completed) + ended.sentFiles = sent->take(); + if (!FileTracking::asksDestinationState(ended, isDirectory)) + return onEnded(ended); askStatesThen( session, {destination}, - [onEnded, sentFiles = sent->take()](DestinationStates::States const& states) + [onEnded, ended = std::move(ended)](DestinationStates::States const& states) { - onEnded({ - .reason = SharedData::OperationCompletionReason::Completed, - .destinationState = states.front(), - .sentFiles = sentFiles, - }); + auto told = ended; + told.destinationState = states.front(); + onEnded(told); } ); }; } /** - * @brief Hands each upload of a bulk that ended as @p completed its end: a completed one once the server told - * what it has at its destination, of @p destinations by index, with its source when it is among @p sent. + * @brief Hands each upload of a bulk that ended as @p completed its end, by the index of its request in + * @p sources and @p destinations, with its source when it is among @p sent. A completed one, and one whose + * destination changed on the server, once the server told what it has there. */ static void endUploads( std::weak_ptr const& session, @@ -512,72 +616,44 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote OnUploadEntryEnded const& onEnded ) { - if (completed.reason != SharedData::OperationCompletionReason::Completed) - { - for (std::size_t index = 0; index < destinations.size(); ++index) - onEnded(index, {.reason = completed.reason}); - return; - } - const auto skipped = completed.indexedFailures | + const auto sentBySource = sent | std::views::transform( - [](SharedData::IndexedFailure const& failure) + [](FileTracking::SentFile const& file) { - return std::pair{static_cast(failure.index), failure.type}; + return std::pair{file.path.toUtf8(), file}; } ) | std::ranges::to(); - std::vector uploaded; - std::vector uploadedDestinations; - for (std::size_t index = 0; index < destinations.size(); ++index) + auto ends = FileTracking::entryEndsOf(completed, sources); + std::vector> asking; + std::vector askedDestinations; + for (std::size_t index = 0; index < ends.size(); ++index) { - if (const auto failure = skipped.find(index); failure != skipped.end()) - onEnded( - index, - { - .reason = SharedData::OperationCompletionReason::Failed, - .changedOnServer = failure->second == SharedData::OperationErrorType::ChangedSinceCompare, - } - ); - else + auto& ended = ends[index]; + if (ended.reason == SharedData::OperationCompletionReason::Completed) + if (const auto found = sentBySource.find(sources[index].toUtf8()); found != sentBySource.end()) + ended.sentFiles = {found->second}; + if (!FileTracking::asksDestinationState(ended, /*isDirectory=*/false)) { - uploaded.push_back(index); - uploadedDestinations.push_back(destinations[index]); + onEnded(index, ended); + continue; } + asking.emplace_back(index, std::move(ended)); + askedDestinations.push_back(destinations[index]); } - if (uploaded.empty()) + if (asking.empty()) return; - const auto sentBySource = sent | - std::views::transform( - [](FileTracking::SentFile const& file) - { - return std::pair{file.path.toUtf8(), file}; - } - ) | - std::ranges::to(); - const auto sentFilesOf = [&sentBySource, &sources](std::size_t index) - { - const auto found = sentBySource.find(sources[index].toUtf8()); - if (found == sentBySource.end()) - return std::vector{}; - return std::vector{found->second}; - }; - auto uploadedSent = uploaded | std::views::transform(sentFilesOf) | std::ranges::to(); askStatesThen( session, - std::move(uploadedDestinations), - [onEnded, - uploaded = std::move(uploaded), - uploadedSent = std::move(uploadedSent)](DestinationStates::States const& states) + std::move(askedDestinations), + [onEnded, asking = std::move(asking)](DestinationStates::States const& states) { - for (std::size_t position = 0; position < uploaded.size(); ++position) - onEnded( - uploaded[position], - { - .reason = SharedData::OperationCompletionReason::Completed, - .destinationState = states[position], - .sentFiles = uploadedSent[position], - } - ); + for (std::size_t position = 0; position < asking.size(); ++position) + { + auto told = asking[position].second; + told.destinationState = states[position]; + onEnded(asking[position].first, told); + } } ); } From db8b4325247bd6cdcf62e04db7c6d215465d2726 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:08:04 +0200 Subject: [PATCH 027/114] Initialised every field of the transfer progress in the progress tests --- backend/test/backend/test_bulk_entry_progress.hpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/test/backend/test_bulk_entry_progress.hpp b/backend/test/backend/test_bulk_entry_progress.hpp index 881d9d83..6428b08c 100644 --- a/backend/test/backend/test_bulk_entry_progress.hpp +++ b/backend/test/backend/test_bulk_entry_progress.hpp @@ -216,7 +216,9 @@ namespace Test::BulkEntryProgressTest TEST(BulkEntryProgressTests, ASingleTransferTellsItsPositionBetweenItsBounds) { const auto source = local("/home/user/a.txt"); - const auto running = FileTracking::uploadProgressOf({.min = 0u, .max = 100u, .current = 40u}, source); + const auto running = FileTracking::uploadProgressOf( + {.operationId = {}, .min = 0u, .max = 100u, .current = 40u, .bytesPerSecond = 0}, source + ); EXPECT_EQ(running.bytesSent, 40u); EXPECT_EQ(running.bytesTotal, 100u); EXPECT_EQ(running.filesDone, 0u); @@ -227,14 +229,18 @@ namespace Test::BulkEntryProgressTest EXPECT_EQ(running.inFlight[0].bytes, 40u); EXPECT_EQ(running.inFlight[0].totalBytes, 100u); - const auto sent = FileTracking::uploadProgressOf({.min = 0u, .max = 100u, .current = 100u}, source); + const auto sent = FileTracking::uploadProgressOf( + {.operationId = {}, .min = 0u, .max = 100u, .current = 100u, .bytesPerSecond = 0}, source + ); EXPECT_TRUE(sent.transferred); EXPECT_EQ(sent.bytesSent, 100u); EXPECT_EQ(sent.filesDone, 1u); EXPECT_TRUE(sent.inFlight.empty()); // A link or a vanished source shows as done without bytes. - const auto link = FileTracking::uploadProgressOf({.min = 1u, .max = 1u, .current = 1u}, source); + const auto link = FileTracking::uploadProgressOf( + {.operationId = {}, .min = 1u, .max = 1u, .current = 1u, .bytesPerSecond = 0}, source + ); EXPECT_TRUE(link.transferred); EXPECT_EQ(link.bytesSent, 0u); EXPECT_EQ(link.bytesTotal, 0u); From 58ca14275997fbda27b91335aabb7cd167d8fe2b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:08:23 +0200 Subject: [PATCH 028/114] Declared the pure file tracking model, attention, in-flight rows and texts --- .../file_tracking/attention_state.hpp | 88 +++++++++ .../file_tracking/file_tracking_texts.hpp | 178 +++++++++++++++++ .../file_tracking/in_flight_rows.hpp | 61 ++++++ .../file_tracking/panel_state.hpp | 62 ++++++ .../file_tracking/tracking_model.hpp | 182 ++++++++++++++++++ .../file_tracking/file_tracking_texts.cpp | 74 +++++++ .../file_tracking/tracking_model.cpp | 134 +++++++++++++ frontend/test/frontend/CMakeLists.txt | 2 + frontend/test/frontend/main.cpp | 3 + .../test/frontend/test_attention_state.hpp | 5 + .../test/frontend/test_in_flight_rows.hpp | 5 + .../test/frontend/test_tracking_model.hpp | 5 + 12 files changed, 799 insertions(+) create mode 100644 frontend/include/frontend/session_components/file_tracking/attention_state.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/panel_state.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/tracking_model.hpp create mode 100644 frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/tracking_model.cpp create mode 100644 frontend/test/frontend/test_attention_state.hpp create mode 100644 frontend/test/frontend/test_in_flight_rows.hpp create mode 100644 frontend/test/frontend/test_tracking_model.hpp diff --git a/frontend/include/frontend/session_components/file_tracking/attention_state.hpp b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp new file mode 100644 index 00000000..ee768e03 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief What waits for the user across every watch of a session. + */ + struct AttentionCounts + { + std::uint64_t conflicts{0u}; + std::uint64_t failed{0u}; + + friend bool operator==(AttentionCounts const&, AttentionCounts const&) = default; + }; + + /** + * @brief What the dock tab shows: the counts as badges, and whether to play the glow now. + */ + struct Attention + { + AttentionCounts counts{}; + bool pulse{false}; + + friend bool operator==(Attention const&, Attention const&) = default; + }; + + /** + * @brief Decides when the dock tab glows and whether a click on it should jump to the newest conflict. Pure, + * tested natively; DockTabAttention feeds it and forwards its results to the tab. + */ + class AttentionState + { + public: + /** + * @brief How long one glow plays. Conflicts arriving within this time of the last pulse do not pulse again. + */ + static constexpr std::chrono::milliseconds pulseWindow{2500}; + + /** + * @brief Takes the current counts, seen at @p now (any steady clock). More conflicts than before make them + * unseen and pulse, unless the last pulse began less than @ref pulseWindow ago. Failures never pulse. + * No conflicts left clears unseen. + * @return What the tab shows now, nothing when neither the counts changed nor a pulse is due. + */ + std::optional + update([[maybe_unused]] AttentionCounts const& counts, [[maybe_unused]] std::chrono::milliseconds now) + { + return std::nullopt; + } + + /** + * @return Whether conflicts arrived since the last @ref acknowledge and some are still there. + */ + bool unseen() const + { + return unseen_; + } + + /** + * @brief The user jumped to the newest conflict, what arrived so far counts as seen. + */ + void acknowledge() + {} + + /** + * @return The counts of the last @ref update. + */ + AttentionCounts const& counts() const + { + return counts_; + } + + /** + * @brief Forgets the counts, unseen and the last pulse, the session ended. + */ + void reset() + {} + + private: + AttentionCounts counts_{}; + std::optional lastPulse_{}; + bool unseen_{false}; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp new file mode 100644 index 00000000..29fa6e5b --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace FileTracking +{ + /** + * @brief Translated texts the pure parts format. Placeholders are fmt "{}" fields, filled in the order given on + * each field. translatedTexts() in translated_texts.hpp fills them from the language files; tests fill them + * by hand. + */ + struct FileTrackingTexts + { + /** + * @brief The counts beside a watch in the outliner, also used by its status badge. + */ + struct Counts + { + /** @brief "1 conflict". */ + std::string conflictOne{}; + /** @brief "{} conflicts" (count). */ + std::string conflicts{}; + /** @brief "{} failed" (count). */ + std::string failed{}; + }; + + /** + * @brief The short reason beside a stopped watch in the outliner. + */ + struct StopSides + { + /** @brief "stopped". */ + std::string stoppedByUser{}; + /** @brief "overlap". */ + std::string overlap{}; + /** @brief "deleted". */ + std::string copyGone{}; + /** @brief "connection lost". */ + std::string connectionLost{}; + }; + + /** + * @brief The status badge texts that are not counts. + */ + struct Statuses + { + /** @brief "Syncing". */ + std::string syncing{}; + /** @brief "Up to date". */ + std::string upToDate{}; + /** @brief "Stopped". */ + std::string stopped{}; + }; + + /** + * @brief The sentence of the stop reason strip. + */ + struct StopReasons + { + /** @brief "You stopped the watch at {}." (clock time). */ + std::string stoppedByUser{}; + /** @brief "Moved into {}, which the watch “{}” covers." (moved-to path, other watch's name). */ + std::string overlap{}; + /** @brief "Moved onto the server path of another watch.", an overlap that lacks the details. */ + std::string overlapUnknown{}; + /** @brief "The copy was deleted or renamed. The server's file is unchanged." */ + std::string copyGone{}; + /** @brief "The connection was lost at {}, the watch stopped." (clock time). */ + std::string connectionLost{}; + }; + + /** + * @brief One sentence per SharedData::UploadFailureReason. + */ + struct FailureReasons + { + std::string lockedLocally{}; + std::string permissionDeniedLocally{}; + std::string unreadableLocally{}; + std::string permissionDeniedOnServer{}; + std::string noSpaceOnServer{}; + std::string serverRefused{}; + std::string connectionProblem{}; + std::string notQueued{}; + std::string unknown{}; + }; + + /** + * @brief The line under a conflicting file. + */ + struct ConflictLines + { + /** @brief "Changed on the server at {}, saved here at {}" (server time, local time). */ + std::string changedOnServer{}; + /** @brief "Deleted on the server, saved here at {}" (local time). */ + std::string deletedOnServer{}; + /** @brief "Created on the server at {}, saved here at {}" (server time, local time). */ + std::string createdOnServer{}; + /** @brief "--:--", stands in for a time that is not known. */ + std::string timeUnknown{}; + }; + + Counts counts{}; + StopSides stopSides{}; + Statuses statuses{}; + StopReasons stopReasons{}; + FailureReasons failureReasons{}; + ConflictLines conflictLines{}; + }; + + /** + * @brief "1 conflict" or "{} conflicts" for @p count. + */ + std::string conflictCountText(FileTrackingTexts const& texts, std::uint64_t count); + + /** + * @brief "{} failed" for @p count. + */ + std::string failedCountText(FileTrackingTexts const& texts, std::uint64_t count); + + /** + * @brief The short outliner reason of a watch that stopped for @p reason. + */ + std::string stopSideText(FileTrackingTexts const& texts, SharedData::WatchStopReason reason); + + /** + * @brief The sentence of the stop reason strip for @p stop. Overlap uses @ref + * FileTrackingTexts::StopReasons::overlap when movedTo and otherWatchName are set, overlapUnknown otherwise. + */ + std::string stopReasonText(FileTrackingTexts const& texts, SharedData::WatchStop const& stop); + + /** + * @brief The sentence for @p reason. + */ + std::string failureReasonText(FileTrackingTexts const& texts, SharedData::UploadFailureReason reason); + + /** + * @brief The line under a conflicting file, with its times as local clock times, timeUnknown for a missing one. + */ + std::string conflictLineText(FileTrackingTexts const& texts, SharedData::WatchConflict const& conflict); + + /** + * @brief "HH:MM" of @p secondsSinceEpoch, a UTC time, shifted by @p utcOffsetSeconds. + */ + std::string clockTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds); + + /** + * @brief The offset of the local time zone from UTC at @p secondsSinceEpoch, in seconds. + */ + std::int64_t localUtcOffset(std::uint64_t secondsSinceEpoch); + + /** + * @brief "HH:MM" of @p secondsSinceEpoch in local time. + */ + std::string localClockTime(std::uint64_t secondsSinceEpoch); + + /** + * @brief "YYYY-MM-DD HH:MM" of @p secondsSinceEpoch in local time. + */ + std::string localDateTime(std::uint64_t secondsSinceEpoch); + + /** + * @brief @p bytes with its unit, as Utility::formatBytes writes it. + */ + std::string sizeText(std::uint64_t bytes); + + /** + * @brief How a file of a watch is shown in its lists: its path below @p watched with '/' separators, or its file + * name when @p path is @p watched itself or not below it. + */ + std::string pathWithin(Paths::LocalPath const& watched, Paths::LocalPath const& path); +} diff --git a/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp b/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp new file mode 100644 index 00000000..176004d7 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief Hands the files in flight out to a list of rows so nothing shifts while they move: a key keeps its row + * while it stays in flight, a new key takes the first free row and appends one when all are taken, and free + * rows at the end are dropped. Generalised from DisplayedBulkOperation's in-flight rows. Pure, tested + * natively; the store mirrors the rows onto observed rows of the progress block. + */ + template + class InFlightRows + { + public: + /** + * @brief Takes the keys in flight now: rows whose key is not among @p keys become free, then every key of + * @p keys without a row claims one, then free rows at the end are dropped. + */ + void update([[maybe_unused]] std::vector const& keys) + {} + + /** + * @return The key of each row, nothing for a free row. Never ends with a free row. + */ + std::vector> const& rows() const + { + return rows_; + } + + /** + * @return The row of @p key, nothing when it is not in flight. + */ + std::optional rowOf([[maybe_unused]] KeyT const& key) const + { + return std::nullopt; + } + + /** + * @return The number of rows, free ones in between included. + */ + std::size_t size() const + { + return rows_.size(); + } + + /** + * @brief Drops every row. + */ + void clear() + { + rows_.clear(); + } + + private: + std::vector> rows_{}; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/panel_state.hpp b/frontend/include/frontend/session_components/file_tracking/panel_state.hpp new file mode 100644 index 00000000..6c05629a --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/panel_state.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include +#include + +namespace FileTracking +{ + /** + * @brief The groups of the outliner, in the order they are shown. Their names are the values of data-ft-group. + */ + BOOST_DEFINE_ENUM_CLASS(OutlinerGroup, NeedsYou, Syncing, Watching, Stopped, Orphaned) + + /** + * @brief Every outliner group in display order. + */ + inline constexpr std::array outlinerGroupOrder{ + OutlinerGroup::NeedsYou, + OutlinerGroup::Syncing, + OutlinerGroup::Watching, + OutlinerGroup::Stopped, + OutlinerGroup::Orphaned, + }; + + /** + * @brief What a watch is doing, shown by its glyph and status badge. Its names are the values of data-ft-state and + * data-ft-status. Conflicts wins over Failed when a watch has both. + */ + BOOST_DEFINE_ENUM_CLASS(WatchStatus, Watching, Syncing, Conflicts, Failed, Stopped) + + /** + * @brief The tabs of the watch detail, in the order they are shown. Their names are the data-tab-key values. + */ + BOOST_DEFINE_ENUM_CLASS(DetailTab, Conflicts, Failed, Options) + + /** + * @brief Every detail tab in display order. + */ + inline constexpr std::array detailTabOrder{ + DetailTab::Conflicts, + DetailTab::Failed, + DetailTab::Options, + }; + + /** + * @brief How Up, Down, Home and End move the selection through the expanded outliner groups. + */ + enum class SelectionMove + { + Previous, + Next, + First, + Last, + }; + + /** + * @brief What the detail pane shows: nothing, a watch, or an orphaned copy. + */ + using Selection = std::variant; +} diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp new file mode 100644 index 00000000..0d6ecc7d --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief Whether @p name passes the outliner filter: an empty filter passes everything, otherwise @p name has to + * contain @p filter, ASCII letters compared without case. + */ + bool matchesFilter(std::string_view name, std::string_view filter); + + /** + * @brief Everything the tracker of one session told the panel, and the rules that sort it into the outliner. Pure, + * tested natively; TrackingStore projects it onto observed values. + */ + class TrackingModel + { + public: + /** + * @brief Adds the watch of @p entry or replaces what is known of it. Stopped entries have stop set. + */ + void showEntry(SharedData::TrackedEntry const& entry); + + /** + * @brief Forgets the watch @p watchId with its progress, conflicts and failures. + */ + void removeEntry(Ids::WatchId const& watchId); + + /** + * @brief Adds @p conflict or replaces the one with its id. Kept even while its watch is not known yet. + */ + void showConflict(SharedData::WatchConflict const& conflict); + + /** + * @brief Forgets the conflict @p conflictId, it was answered or dropped. + */ + void removeConflict(Ids::ConflictId const& conflictId); + + /** + * @brief Adds @p failure or replaces the one with its id (a repeated failure keeps its id and counts attempts). + */ + void showFailure(SharedData::UploadFailure const& failure); + + /** + * @brief Forgets the failure @p failureId, it went through, was retried or dismissed. + */ + void removeFailure(Ids::FailureId const& failureId); + + /** + * @brief Replaces the progress of the watch @p progress names. + */ + void showWatchProgress(SharedData::WatchProgress const& progress); + + /** + * @brief Replaces the list of orphaned copies. + */ + void showOrphanedCopies(SharedData::OrphanedCopies const& copies); + + /** + * @brief Forgets everything, the session ended. + */ + void clear(); + + /** + * @return The entry of @p watchId, nullptr when it is not known. + */ + SharedData::TrackedEntry const* entryOf(Ids::WatchId const& watchId) const; + + /** + * @return The last progress of @p watchId, nullptr when none arrived. + */ + SharedData::WatchProgress const* progressOf(Ids::WatchId const& watchId) const; + + /** + * @return The orphaned copy @p instanceId, nullptr when it is not known or an entry still holds its instance. + */ + SharedData::OrphanedCopy const* orphanOf(Ids::InstanceId const& instanceId) const; + + /** + * @return The watch the conflict @p conflictId belongs to, nothing when the conflict is not known. + */ + std::optional watchOfConflict(Ids::ConflictId const& conflictId) const; + + /** + * @return The watch the failure @p failureId belongs to, nothing when the failure is not known. + */ + std::optional watchOfFailure(Ids::FailureId const& failureId) const; + + /** + * @return The conflicts of @p watchId, newest raisedAt first, ties by conflict id. + */ + std::vector conflictsOf(Ids::WatchId const& watchId) const; + + /** + * @return The failures of @p watchId, newest failedAt first, ties by failure id. + */ + std::vector failuresOf(Ids::WatchId const& watchId) const; + + /** + * @return The number of conflicts of @p watchId. + */ + std::uint64_t conflictCountOf(Ids::WatchId const& watchId) const; + + /** + * @return The number of failures of @p watchId. + */ + std::uint64_t failureCountOf(Ids::WatchId const& watchId) const; + + /** + * @return The conflicts of every known watch, for the dock tab. + */ + std::uint64_t conflictCount() const; + + /** + * @return The failures of every known watch, for the dock tab. + */ + std::uint64_t failureCount() const; + + /** + * @brief Stopped when stop is set; Conflicts when it has conflicts; Failed when it has failures; Syncing when + * changes are pending or the entry is uploading; Watching otherwise. Watching for an unknown watch. + */ + WatchStatus statusOf(Ids::WatchId const& watchId) const; + + /** + * @brief Stopped for Stopped, NeedsYou for Conflicts and Failed, Syncing for Syncing, Watching otherwise. + */ + OutlinerGroup groupOf(Ids::WatchId const& watchId) const; + + /** + * @return The members of @p group that pass @p filter, sorted by name without case, ties by id: watch ids for + * the watch groups, instance ids for Orphaned. Orphans whose instance an entry still holds (a stopped + * copy) are left out. + */ + std::vector members(OutlinerGroup group, std::string_view filter) const; + + /** + * @return What the outliner shows beside @p watchId: the conflict count when it has conflicts, else the failed + * count when it has failures, else the stop side text when it stopped, else nothing. + */ + std::string sideTextOf(Ids::WatchId const& watchId, FileTrackingTexts const& texts) const; + + /** + * @return The text of the status badge of @p watchId: the conflict or failed count, Syncing, Up to date or + * Stopped, following @ref statusOf. + */ + std::string statusTextOf(Ids::WatchId const& watchId, FileTrackingTexts const& texts) const; + + /** + * @return How far the current burst of @p watchId is, 0 to 1: bytesDone / bytesTotal when bytesTotal > 0, else + * changesDone / (changesDone + changesPending), 0 without progress or changes. + */ + double ringFractionOf(Ids::WatchId const& watchId) const; + + /** + * @return The conflict with the latest raisedAt among the known watches, ties by the greater conflict id. + */ + std::optional newestConflict() const; + + private: + std::map entries_{}; + std::map progress_{}; + std::map conflicts_{}; + std::map failures_{}; + std::vector orphans_{}; + }; +} diff --git a/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp b/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp new file mode 100644 index 00000000..0f2e09b8 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp @@ -0,0 +1,74 @@ +#include + +namespace FileTracking +{ + std::string conflictCountText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] std::uint64_t count) + { + return {}; + } + + std::string failedCountText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] std::uint64_t count) + { + return {}; + } + + std::string + stopSideText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] SharedData::WatchStopReason reason) + { + return {}; + } + + std::string + stopReasonText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] SharedData::WatchStop const& stop) + { + return {}; + } + + std::string failureReasonText( + [[maybe_unused]] FileTrackingTexts const& texts, + [[maybe_unused]] SharedData::UploadFailureReason reason + ) + { + return {}; + } + + std::string conflictLineText( + [[maybe_unused]] FileTrackingTexts const& texts, + [[maybe_unused]] SharedData::WatchConflict const& conflict + ) + { + return {}; + } + + std::string + clockTime([[maybe_unused]] std::uint64_t secondsSinceEpoch, [[maybe_unused]] std::int64_t utcOffsetSeconds) + { + return {}; + } + + std::int64_t localUtcOffset([[maybe_unused]] std::uint64_t secondsSinceEpoch) + { + return 0; + } + + std::string localClockTime([[maybe_unused]] std::uint64_t secondsSinceEpoch) + { + return {}; + } + + std::string localDateTime([[maybe_unused]] std::uint64_t secondsSinceEpoch) + { + return {}; + } + + std::string sizeText([[maybe_unused]] std::uint64_t bytes) + { + return {}; + } + + std::string + pathWithin([[maybe_unused]] Paths::LocalPath const& watched, [[maybe_unused]] Paths::LocalPath const& path) + { + return {}; + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp new file mode 100644 index 00000000..073494ab --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp @@ -0,0 +1,134 @@ +#include + +namespace FileTracking +{ + bool matchesFilter([[maybe_unused]] std::string_view name, [[maybe_unused]] std::string_view filter) + { + return true; + } + + void TrackingModel::showEntry([[maybe_unused]] SharedData::TrackedEntry const& entry) + {} + + void TrackingModel::removeEntry([[maybe_unused]] Ids::WatchId const& watchId) + {} + + void TrackingModel::showConflict([[maybe_unused]] SharedData::WatchConflict const& conflict) + {} + + void TrackingModel::removeConflict([[maybe_unused]] Ids::ConflictId const& conflictId) + {} + + void TrackingModel::showFailure([[maybe_unused]] SharedData::UploadFailure const& failure) + {} + + void TrackingModel::removeFailure([[maybe_unused]] Ids::FailureId const& failureId) + {} + + void TrackingModel::showWatchProgress([[maybe_unused]] SharedData::WatchProgress const& progress) + {} + + void TrackingModel::showOrphanedCopies([[maybe_unused]] SharedData::OrphanedCopies const& copies) + {} + + void TrackingModel::clear() + {} + + SharedData::TrackedEntry const* TrackingModel::entryOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return nullptr; + } + + SharedData::WatchProgress const* TrackingModel::progressOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return nullptr; + } + + SharedData::OrphanedCopy const* TrackingModel::orphanOf([[maybe_unused]] Ids::InstanceId const& instanceId) const + { + return nullptr; + } + + std::optional TrackingModel::watchOfConflict([[maybe_unused]] Ids::ConflictId const& conflictId) const + { + return std::nullopt; + } + + std::optional TrackingModel::watchOfFailure([[maybe_unused]] Ids::FailureId const& failureId) const + { + return std::nullopt; + } + + std::vector + TrackingModel::conflictsOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return {}; + } + + std::vector TrackingModel::failuresOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return {}; + } + + std::uint64_t TrackingModel::conflictCountOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return 0u; + } + + std::uint64_t TrackingModel::failureCountOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return 0u; + } + + std::uint64_t TrackingModel::conflictCount() const + { + return 0u; + } + + std::uint64_t TrackingModel::failureCount() const + { + return 0u; + } + + WatchStatus TrackingModel::statusOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return WatchStatus::Watching; + } + + OutlinerGroup TrackingModel::groupOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return OutlinerGroup::Watching; + } + + std::vector + TrackingModel::members([[maybe_unused]] OutlinerGroup group, [[maybe_unused]] std::string_view filter) const + { + return {}; + } + + std::string TrackingModel::sideTextOf( + [[maybe_unused]] Ids::WatchId const& watchId, + [[maybe_unused]] FileTrackingTexts const& texts + ) const + { + return {}; + } + + std::string TrackingModel::statusTextOf( + [[maybe_unused]] Ids::WatchId const& watchId, + [[maybe_unused]] FileTrackingTexts const& texts + ) const + { + return {}; + } + + double TrackingModel::ringFractionOf([[maybe_unused]] Ids::WatchId const& watchId) const + { + return 0.0; + } + + std::optional TrackingModel::newestConflict() const + { + return std::nullopt; + } +} diff --git a/frontend/test/frontend/CMakeLists.txt b/frontend/test/frontend/CMakeLists.txt index 2de6d5a2..cd320dec 100644 --- a/frontend/test/frontend/CMakeLists.txt +++ b/frontend/test/frontend/CMakeLists.txt @@ -3,6 +3,8 @@ include(GoogleTest) add_executable( nui-sftp-frontend-test main.cpp + "${CMAKE_SOURCE_DIR}/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp" + "${CMAKE_SOURCE_DIR}/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp" ) target_include_directories( diff --git a/frontend/test/frontend/main.cpp b/frontend/test/frontend/main.cpp index a7117012..72204b0f 100644 --- a/frontend/test/frontend/main.cpp +++ b/frontend/test/frontend/main.cpp @@ -1,4 +1,6 @@ +#include "test_attention_state.hpp" #include "test_drop_text.hpp" +#include "test_in_flight_rows.hpp" #include "test_item_names.hpp" #include "test_item_order.hpp" #include "test_numbered_saves.hpp" @@ -6,6 +8,7 @@ #include "test_row_order.hpp" #include "test_selection_model.hpp" #include "test_side_paths.hpp" +#include "test_tracking_model.hpp" #include "test_transfer_target.hpp" #include diff --git a/frontend/test/frontend/test_attention_state.hpp b/frontend/test/frontend/test_attention_state.hpp new file mode 100644 index 00000000..5fc6bbbe --- /dev/null +++ b/frontend/test/frontend/test_attention_state.hpp @@ -0,0 +1,5 @@ +#pragma once + +#include + +#include diff --git a/frontend/test/frontend/test_in_flight_rows.hpp b/frontend/test/frontend/test_in_flight_rows.hpp new file mode 100644 index 00000000..f877763c --- /dev/null +++ b/frontend/test/frontend/test_in_flight_rows.hpp @@ -0,0 +1,5 @@ +#pragma once + +#include + +#include diff --git a/frontend/test/frontend/test_tracking_model.hpp b/frontend/test/frontend/test_tracking_model.hpp new file mode 100644 index 00000000..ad15d82e --- /dev/null +++ b/frontend/test/frontend/test_tracking_model.hpp @@ -0,0 +1,5 @@ +#pragma once + +#include + +#include From 3bda223e5e7085c99a6c900bf78c07a137a41aac Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:09:31 +0200 Subject: [PATCH 029/114] Added the texts of the redesigned file tracking panel in English and German --- static/assets/languages/de_DE.yaml | 95 ++++++++++++++++++++++++++++++ static/assets/languages/en_US.yaml | 95 ++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index c670cb3c..2cba7124 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -524,6 +524,101 @@ fileTracking: progressWaitingForAnswer: "{} warten auf Ihre Antwort" watchEndedCopyGone: "Die Beobachtung von {} wurde beendet, die temporäre Kopie wurde gelöscht oder umbenannt. Die Datei auf dem Server ist unverändert, mit 'Öffnen und Beobachten' können Sie sie erneut bearbeiten." watchesEndedConnectionLost: "Die Verbindung wurde unterbrochen, die Beobachtung von {} wurde beendet. Beobachten Sie sie erneut, sobald die Sitzung wieder verbunden ist." + outlinerLabel: "Beobachtungen" + filterPlaceholder: "Beobachtungen filtern" + groupNeedsYou: "Wartet auf Sie" + groupSyncing: "Wird synchronisiert" + groupStopped: "Beendet" + groupOrphaned: "Verwaiste Kopien" + removeAllStoppedTooltip: "Alle beendeten Beobachtungen entfernen" + deleteAllOrphansTooltip: "Alle verwaisten Kopien löschen" + removeStoppedTooltip: "{} aus der Liste entfernen" + deleteOrphanTooltip: "Die Kopie {} löschen" + syncingTooltip: "{}, {} % hochgeladen" + conflictCountOne: "1 Konflikt" + conflictCount: "{} Konflikte" + failedCount: "{} fehlgeschlagen" + sideStoppedByUser: "beendet" + sideOverlap: "Überschneidung" + sideCopyGone: "gelöscht" + sideConnectionLost: "Verbindung verloren" + statusUpToDate: "Aktuell" + temporaryCopy: "temporäre Kopie" + openLocalItem: "Lokal öffnen" + revealOnServer: "Auf dem Server zeigen" + removeButton: "Entfernen" + stopReasonStoppedByUser: "Sie haben die Beobachtung um {} beendet." + stopReasonOverlap: "Nach {} verschoben, das die Beobachtung „{}“ abdeckt." + stopReasonOverlapUnknown: "Auf den Serverpfad einer anderen Beobachtung verschoben." + stopReasonCopyGone: "Die Kopie wurde gelöscht oder umbenannt. Die Datei auf dem Server ist unverändert." + stopReasonConnectionLost: "Die Verbindung ist um {} abgebrochen, die Beobachtung wurde beendet." + showOtherWatch: "„{}“ zeigen" + openAgain: "Erneut öffnen" + progressLabel: "Fortschritt" + progressFiles: "{} von {} Dateien" + progressWaiting: "{} warten" + progressUpToDateLastUpload: "Aktuell · zuletzt hochgeladen um {}" + progressStoppedLastUpload: "Beendet · zuletzt hochgeladen um {}" + progressConflictsWaitOne: "Eine Änderung wartet unter Konflikte" + progressConflictsWait: "{} Änderungen warten unter Konflikte" + progressFilesUpToDate: "{} von {} Dateien aktuell" + detailTabsLabel: "Details der Beobachtung" + tabConflicts: "Konflikte" + tabFailed: "Fehlgeschlagen" + tabOptions: "Optionen" + replaceAllOnServer: "Alle auf dem Server ersetzen" + keepAllServers: "Alle vom Server behalten" + keepAsksAgain: "Behalten fragt beim nächsten Speichern erneut" + replaceOnServer: "Auf dem Server ersetzen" + keepServers: "Vom Server behalten" + conflictChangedOnServer: "Auf dem Server um {} geändert, hier um {} gespeichert" + conflictDeletedOnServer: "Auf dem Server gelöscht, hier um {} gespeichert" + conflictCreatedOnServer: "Auf dem Server um {} angelegt, hier um {} gespeichert" + timeUnknown: "--:--" + noConflicts: "Keine Konflikte." + retryAll: "Alle wiederholen" + dismissAll: "Alle verwerfen" + notRetriedAutomatically: "Wird nicht automatisch wiederholt" + retry: "Wiederholen" + dismiss: "Verwerfen" + dismissFailureLabel: "{} verwerfen" + failedTimes: "{}-mal fehlgeschlagen" + noFailures: "Keine fehlgeschlagenen Uploads." + reasonLockedLocally: "Ein anderes Programm hält die Datei exklusiv geöffnet." + reasonPermissionDeniedLocally: "Die Datei konnte hier nicht gelesen werden: Zugriff verweigert." + reasonUnreadableLocally: "Die Datei konnte hier nicht gelesen werden." + reasonPermissionDeniedOnServer: "Der Server hat das Schreiben abgelehnt: Zugriff verweigert." + reasonNoSpaceOnServer: "Auf dem Server ist kein Speicherplatz mehr frei." + reasonServerRefused: "Der Server hat das Schreiben abgelehnt." + reasonConnectionProblem: "Die Verbindung zum Server ist fehlgeschlagen." + reasonNotQueued: "Der Upload konnte nicht in die Warteschlange gestellt werden." + reasonUnknown: "Der Upload ist fehlgeschlagen." + optionAutoReuploadDescription: "Jede gespeicherte Änderung hochladen" + optionMoveRemoteDescription: "Die Kopie auf dem Server mitverschieben, wenn das Element verschoben wird" + optionDeleteRemoteDescription: "Die Kopie auf dem Server löschen, wenn das Element gelöscht wird" + optionCheckRemoteDescription: "Nachfragen, bevor eine auf dem Server geänderte Kopie ersetzt wird" + optionNeverForCopy: "Nie bei einer temporären Kopie" + orphanKind: "Verwaiste Kopie" + showFolder: "Ordner zeigen" + deleteCopy: "Kopie löschen" + orphanExplanation: "Die Beobachtung dieser Kopie ist beendet. Löschen Sie sie, sobald Sie sie nicht mehr brauchen." + orphanDownloadedFrom: "Heruntergeladen von {}" + orphanOriginUnknown: "Woher sie heruntergeladen wurde, ist nicht bekannt." + orphanDownloadedAt: "Heruntergeladen am {}" + orphanEndedAt: "Beobachtung beendet am {}" + orphanSize: "Größe: {}" + confirmDeleteOrphanHeader: "Kopie löschen?" + confirmDeleteOrphanText: "Die Kopie '{}' von diesem Computer löschen? Die Datei auf dem Server bleibt unverändert." + confirmDeleteAllOrphansHeader: "Alle verwaisten Kopien löschen?" + confirmDeleteAllOrphansText: "{} verwaiste Kopien von diesem Computer löschen? Die Dateien auf dem Server bleiben unverändert." + deleteOrphanFailed: "Die Kopie '{}' wurde nicht gelöscht: {}" + watchEndedOverlap: "Die Beobachtung von {} wurde beendet, das Element wurde auf den Serverpfad einer anderen Beobachtung verschoben." + nothingSelected: "Wählen Sie eine Beobachtung aus, um ihre Details zu sehen." + nothingTracked: "Noch wird nichts beobachtet. Nutzen Sie 'Öffnen und Beobachten' oder 'Hochladen und Beobachten' im Datei-Explorer." + attentionConflictsOne: "1 Konflikt wartet" + attentionConflicts: "{} Konflikte warten" + attentionFailedOne: "1 Upload fehlgeschlagen" + attentionFailed: "{} Uploads fehlgeschlagen" settings: version: "Version" settingsRestartRequiredHeader: "Neustart erforderlich" diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index 8e895307..4eec952a 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -524,6 +524,101 @@ fileTracking: progressWaitingForAnswer: "{} waiting for your answer" watchEndedCopyGone: "The watch of {} ended, its temporary copy was deleted or renamed. The file on the server is unchanged, use Open and Watch to edit it again." watchesEndedConnectionLost: "The connection was lost, the watches of {} ended. Watch them again once the session is connected." + outlinerLabel: "Watches" + filterPlaceholder: "Filter watches" + groupNeedsYou: "Needs you" + groupSyncing: "Syncing" + groupStopped: "Stopped" + groupOrphaned: "Orphaned copies" + removeAllStoppedTooltip: "Remove all stopped watches" + deleteAllOrphansTooltip: "Delete all orphaned copies" + removeStoppedTooltip: "Remove {} from the list" + deleteOrphanTooltip: "Delete the copy {}" + syncingTooltip: "{}, {}% uploaded" + conflictCountOne: "1 conflict" + conflictCount: "{} conflicts" + failedCount: "{} failed" + sideStoppedByUser: "stopped" + sideOverlap: "overlap" + sideCopyGone: "deleted" + sideConnectionLost: "connection lost" + statusUpToDate: "Up to date" + temporaryCopy: "temporary copy" + openLocalItem: "Open Local Item" + revealOnServer: "Reveal on Server" + removeButton: "Remove" + stopReasonStoppedByUser: "You stopped the watch at {}." + stopReasonOverlap: "Moved into {}, which the watch “{}” covers." + stopReasonOverlapUnknown: "Moved onto the server path of another watch." + stopReasonCopyGone: "The copy was deleted or renamed. The server's file is unchanged." + stopReasonConnectionLost: "The connection was lost at {}, the watch stopped." + showOtherWatch: "Show “{}”" + openAgain: "Open Again" + progressLabel: "Progress" + progressFiles: "{} of {} files" + progressWaiting: "{} waiting" + progressUpToDateLastUpload: "Up to date · last upload {}" + progressStoppedLastUpload: "Stopped · last upload {}" + progressConflictsWaitOne: "One change waits in Conflicts" + progressConflictsWait: "{} changes wait in Conflicts" + progressFilesUpToDate: "{} of {} files up to date" + detailTabsLabel: "Watch details" + tabConflicts: "Conflicts" + tabFailed: "Failed" + tabOptions: "Options" + replaceAllOnServer: "Replace All on Server" + keepAllServers: "Keep All Server's" + keepAsksAgain: "Keep asks again on the next save" + replaceOnServer: "Replace on Server" + keepServers: "Keep Server's" + conflictChangedOnServer: "Changed on the server at {}, saved here at {}" + conflictDeletedOnServer: "Deleted on the server, saved here at {}" + conflictCreatedOnServer: "Created on the server at {}, saved here at {}" + timeUnknown: "--:--" + noConflicts: "No conflicts." + retryAll: "Retry All" + dismissAll: "Dismiss All" + notRetriedAutomatically: "Not retried automatically" + retry: "Retry" + dismiss: "Dismiss" + dismissFailureLabel: "Dismiss {}" + failedTimes: "Failed {} times" + noFailures: "No failed uploads." + reasonLockedLocally: "Another program holds the file open exclusively." + reasonPermissionDeniedLocally: "The file could not be read here: permission denied." + reasonUnreadableLocally: "The file could not be read here." + reasonPermissionDeniedOnServer: "The server refused the write: permission denied." + reasonNoSpaceOnServer: "The server has no space left." + reasonServerRefused: "The server refused the write." + reasonConnectionProblem: "The connection to the server failed." + reasonNotQueued: "The upload could not be queued." + reasonUnknown: "The upload failed." + optionAutoReuploadDescription: "Upload every change you save" + optionMoveRemoteDescription: "Move the server's copy when the item moves" + optionDeleteRemoteDescription: "Delete the server's copy when the item is deleted" + optionCheckRemoteDescription: "Ask before replacing a server copy that changed" + optionNeverForCopy: "Never for a temporary copy" + orphanKind: "Orphaned copy" + showFolder: "Show Folder" + deleteCopy: "Delete Copy" + orphanExplanation: "The watch of this copy ended. Delete it once you no longer need it." + orphanDownloadedFrom: "Downloaded from {}" + orphanOriginUnknown: "Where it was downloaded from was not recorded." + orphanDownloadedAt: "Downloaded at {}" + orphanEndedAt: "Watch ended at {}" + orphanSize: "Size: {}" + confirmDeleteOrphanHeader: "Delete the Copy?" + confirmDeleteOrphanText: "Delete the copy '{}' from this computer? The file on the server stays as it is." + confirmDeleteAllOrphansHeader: "Delete All Orphaned Copies?" + confirmDeleteAllOrphansText: "Delete {} orphaned copies from this computer? The files on the server stay as they are." + deleteOrphanFailed: "The copy '{}' was not deleted: {}" + watchEndedOverlap: "The watch of {} stopped, the item moved onto the server path of another watch." + nothingSelected: "Select a watch to see its details." + nothingTracked: "Nothing is watched yet. Use Open and Watch or Upload and Watch in the file explorer." + attentionConflictsOne: "1 conflict waits" + attentionConflicts: "{} conflicts wait" + attentionFailedOne: "1 upload failed" + attentionFailed: "{} uploads failed" settings: version: "Version" settingsRestartRequiredHeader: "Restart Required" From 52c7652eb26a9ac3df3e728e6c0876f3310f7ccc Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:11:23 +0200 Subject: [PATCH 030/114] Declared the file tracking store, tracker client, actions and dock tab attention --- .../file_tracking/dock_tab_attention.hpp | 54 +++++ .../file_tracking/tracker_client.hpp | 159 +++++++++++++++ .../file_tracking/tracking_actions.hpp | 67 +++++++ .../file_tracking/tracking_items.hpp | 132 ++++++++++++ .../file_tracking/tracking_store.hpp | 188 ++++++++++++++++++ .../file_tracking/translated_texts.hpp | 12 ++ .../file_tracking/dock_tab_attention.cpp | 36 ++++ .../file_tracking/tracker_client.cpp | 117 +++++++++++ .../file_tracking/tracking_store.cpp | 162 +++++++++++++++ .../file_tracking/translated_texts.cpp | 56 ++++++ 10 files changed, 983 insertions(+) create mode 100644 frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/tracker_client.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/tracking_items.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/tracking_store.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/translated_texts.hpp create mode 100644 frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/tracker_client.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/tracking_store.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/translated_texts.cpp diff --git a/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp b/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp new file mode 100644 index 00000000..5e93998c --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +#include +#include + +namespace FileTracking +{ + /** + * @brief Makes the File Tracking dock tab of a session count and glow. Feeds AttentionState with the steady clock + * and forwards only changes, through Nui::val::global("contentPanelManager") + * .setFileTrackingAttention(panelId, {conflicts, failed, pulse, conflictsText, failedText}): the counts as + * numbers, pulse true when the glow should play now, and the translated badge titles ("5 conflicts wait", + * "6 uploads failed", empty at 0). + */ + class DockTabAttention + { + public: + /** + * @param panelId The session's layout id (sessionLayoutId), naming its dock panel in contentPanelManager. + */ + explicit DockTabAttention(std::string panelId); + ROAR_PIMPL_SPECIAL_FUNCTIONS_NO_MOVE(DockTabAttention); + + /** + * @brief Takes the counts TrackingStore::onAttentionChanged reports and updates the tab when they or the pulse + * changed. + */ + void show(AttentionCounts const& counts); + + /** + * @return Whether conflicts arrived since the last @ref acknowledge, so a click on the tab should jump to the + * newest conflict. + */ + bool unseen() const; + + /** + * @brief The user jumped to the newest conflict. + */ + void acknowledge(); + + /** + * @brief Forgets everything and shows zero counts, the session ended. + */ + void reset(); + + private: + struct Implementation; + std::unique_ptr impl_; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp b/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp new file mode 100644 index 00000000..90bb244c --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp @@ -0,0 +1,159 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace FileTracking +{ + class TrackingStore; + + /** + * @brief Talks to the tracker of a session, which lives in the backend as "FileTracker::". Registers + * every event and feeds it to the TrackingStore, syncing once per event; has one method per RPC. Owns no + * panel state. A refusal of a fire-and-forget RPC is only logged. + */ + class TrackerClient + { + public: + /** + * @param store Fed by the events, outlives the client. + * @param onEnded Gets the watches that ended without the user stopping them (onEntriesEnded), for the toasts. + */ + TrackerClient(TrackingStore& store, std::function onEnded); + ROAR_PIMPL_SPECIAL_FUNCTIONS_NO_MOVE(TrackerClient); + + /** + * @brief Follows the tracker of @p sessionId from now on: deactivates first, registers onEntry, + * onEntryRemoved, onEntriesEnded, onConflict, onConflictRemoved, onFailure, onFailureRemoved, + * onWatchProgress and onOrphanedCopies, then asks for the orphan list. + */ + void activate(Ids::SessionId sessionId); + + /** + * @brief Unregisters every event and clears the store, the session ended. Does not sync. + */ + void deactivate(); + + /** + * @brief Asks the tracker to download @p remotePath into a directory of its own, and to watch it there when + * @p watch is set. @p onOpened gets the copy, @p onRefused the translated reason when there is none. + * Neither is called when the download did not end well, its card in the queue tells why. + */ + void open( + Paths::RemotePath const& remotePath, + std::uint64_t sizeBytes, + bool watch, + std::function onOpened, + std::function onRefused + ); + + /** + * @brief Asks the tracker whether @p item could be uploaded into @p directory and watched now, before the user + * is asked anything about the upload. @p onWatchable gets the item canonical, as the watch will report + * it; @p onRefused gets the translated reason. + */ + void checkUploadAndWatch( + Paths::LocalPath const& item, + Paths::RemotePath const& directory, + std::function onWatchable, + std::function onRefused + ); + + /** + * @brief Asks the tracker to watch @p item, upload it to @p remotePath and mirror its changes from then on. + * @p onRefused gets the translated reason when it does not. + */ + void uploadAndWatch( + Paths::LocalPath const& item, + Paths::RemotePath const& remotePath, + std::function onRefused + ); + + /** + * @brief Asks the tracker to remove @p localPath, a download of the instance @p instanceId the user decided not + * to open, with its watch. + */ + void discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath); + + /** + * @brief Stops the watch @p watchId (stop), it stays listed as stopped by the user. + */ + void stop(Ids::WatchId const& watchId); + + /** + * @brief Turns @p option of @p watchId on or off (setOption). + */ + void setOption(Ids::WatchId const& watchId, SharedData::WatchOption option, bool enabled); + + /** + * @brief Answers the conflict @p conflictId with @p resolution (answerConflict). + */ + void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution); + + /** + * @brief Answers every conflict of @p watchId with @p resolution (answerAllConflicts). + */ + void answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution); + + /** + * @brief Uploads the file of the failure @p failureId again (retryFailure). + */ + void retryFailure(Ids::FailureId const& failureId); + + /** + * @brief Forgets the failure @p failureId without uploading (dismissFailure). + */ + void dismissFailure(Ids::FailureId const& failureId); + + /** + * @brief Uploads the files of every failure of @p watchId again (retryAllFailures). + */ + void retryAllFailures(Ids::WatchId const& watchId); + + /** + * @brief Forgets every failure of @p watchId (dismissAllFailures). + */ + void dismissAllFailures(Ids::WatchId const& watchId); + + /** + * @brief Drops the stopped watch @p watchId from the list (removeStopped); a stopped copy becomes an orphan. + */ + void removeStopped(Ids::WatchId const& watchId); + + /** + * @brief Drops every stopped watch from the list (removeAllStopped). + */ + void removeAllStopped(); + + /** + * @brief Asks for the orphaned copies (listOrphanedCopies), the list arrives as onOrphanedCopies. + */ + void listOrphanedCopies(); + + /** + * @brief Deletes the orphaned copy @p instanceId (removeOrphanedCopy). @p onFailed gets the backend's error + * when it refused or failed. + */ + void + removeOrphanedCopy(Ids::InstanceId const& instanceId, std::function onFailed); + + /** + * @brief Deletes every orphaned copy (removeAllOrphanedCopies). + */ + void removeAllOrphanedCopies(); + + private: + struct Implementation; + std::unique_ptr impl_; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp new file mode 100644 index 00000000..3776c700 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +namespace FileTracking +{ + /** + * @brief Every command the views trigger. The facade (FileTrackingPanel) fills each one, mostly by calling + * TrackerClient or TrackingStore, and owns this struct for as long as the views exist, so views may capture + * its address. Views call them from DOM event handlers, which sync on their own. + */ + struct TrackingActions + { + /** @brief Replace on Server or Keep Server's for one conflict. */ + std::function + answerConflict{}; + /** @brief Replace All on Server or Keep All Server's for the conflicts of a watch. */ + std::function + answerAllConflicts{}; + /** @brief Retry on one failed upload. */ + std::function retryFailure{}; + /** @brief The dismiss icon of one failed upload. */ + std::function dismissFailure{}; + /** @brief Retry All for the failures of a watch. */ + std::function retryAllFailures{}; + /** @brief Dismiss All for the failures of a watch. */ + std::function dismissAllFailures{}; + /** @brief A switch of the Options tab changed. */ + std::function setOption{}; + /** @brief Stop on a running watch; it stays listed under Stopped. */ + std::function stop{}; + /** @brief Remove on a stopped watch, or the trash icon of its row. */ + std::function removeStopped{}; + /** @brief The trash icon of the Stopped group. */ + std::function removeAllStopped{}; + /** @brief Open Again on a stopped copy: opens its server file again and watches it, then removes the old one. + */ + std::function openAgain{}; + /** @brief Show “Y” on an overlap stop: selects the other watch. */ + std::function showWatch{}; + /** @brief Delete Copy, or the trash icon of an orphan row. The facade asks for confirmation first. */ + std::function deleteOrphanedCopy{}; + /** @brief The trash icon of the Orphaned group. The facade asks for confirmation first. */ + std::function deleteAllOrphanedCopies{}; + /** @brief Open Local Item on a watch, Show Folder on an orphan: opens @p path on this computer. */ + std::function openLocally{}; + /** @brief Reveal on Server: shows @p path in the remote side of the file explorer. */ + std::function revealOnServer{}; + /** @brief A click or Enter on an outliner row. */ + std::function select{}; + /** @brief Up, Down, Home or End in the outliner. */ + std::function moveSelection{}; + /** @brief A detail tab was chosen. */ + std::function chooseTab{}; + /** @brief The outliner filter text changed. */ + std::function filter{}; + /** @brief An outliner group was folded or unfolded. */ + std::function setGroupExpanded{}; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp new file mode 100644 index 00000000..7fc354e5 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief One row of the files in flight of a watch. A free row (between taken ones) has an empty path and name. + */ + struct InFlightFileItem + { + /** @brief The local path, the value of data-ft-progress-file. */ + Nui::Observed path{}; + /** @brief The path below the watched item, shown above the bar. */ + Nui::Observed name{}; + std::shared_ptr> bytes{std::make_shared>(0)}; + std::shared_ptr> totalBytes{std::make_shared>(0)}; + }; + + /** + * @brief One row of the Conflicts tab. Replaced as a whole when the conflict changes. + */ + struct ConflictItem + { + SharedData::WatchConflict conflict{}; + /** @brief The path below the watched item, the file name for a watched file. */ + std::string file{}; + /** @brief "Changed on the server at 14:21, saved here at 14:27". */ + std::string line{}; + }; + + /** + * @brief One row of the Failed tab. Replaced as a whole when the failure changes. + */ + struct FailureItem + { + SharedData::UploadFailure failure{}; + /** @brief The path below the watched item, the file name for a watched file. */ + std::string file{}; + /** @brief The reason sentence. */ + std::string reason{}; + /** @brief The clock time of failedAt. */ + std::string time{}; + }; + + /** + * @brief What the views show of one watch. The store owns it and keeps it while the watch is known, so a view may + * capture its shared pointer. Every member is updated in place, never replaced. + */ + struct WatchItem + { + explicit WatchItem(Ids::WatchId watchId) + : watchId{std::move(watchId)} + {} + + Ids::WatchId watchId; + /** @brief The entry as the tracker sent it last. */ + Nui::Observed entry{}; + Nui::Observed name{}; + /** @brief The watched local item, for a copy its file in the instance directory. */ + Nui::Observed localPath{}; + Nui::Observed remotePath{}; + /** @brief An Open and Watch copy rather than an uploaded original. */ + Nui::Observed downloadedCopy{false}; + Nui::Observed isDirectory{false}; + Nui::Observed status{WatchStatus::Watching}; + /** @brief The status badge text: a count, Syncing, Up to date or Stopped. */ + Nui::Observed statusText{}; + /** @brief The outliner side text: a count or the short stop reason, empty otherwise. */ + Nui::Observed sideText{}; + /** @brief The fraction of the outliner ring while syncing, 0 to 1. */ + std::shared_ptr> ringFraction{std::make_shared>(0.0)}; + /** @brief Set once the watch stopped. */ + Nui::Observed> stop{}; + /** @brief The sentence of the stop reason strip, empty while running. */ + Nui::Observed stopText{}; + + std::shared_ptr> autoReupload{std::make_shared>(false)}; + std::shared_ptr> moveRemote{std::make_shared>(false)}; + std::shared_ptr> deleteRemote{std::make_shared>(false)}; + std::shared_ptr> checkRemote{std::make_shared>(false)}; + + /** @brief The last progress the tracker sent, zero until one arrives. */ + Nui::Observed progress{}; + /** @brief bytesDone of the progress, for the total bar. */ + std::shared_ptr> bytesDone{std::make_shared>(0)}; + /** @brief bytesTotal of the progress, for the total bar. */ + std::shared_ptr> bytesTotal{std::make_shared>(0)}; + /** @brief One row per file in flight, laid out by InFlightRows so rows do not shift. */ + Nui::Observed>> inFlight{}; + + /** @brief Newest first, as TrackingModel::conflictsOf orders them. */ + Nui::Observed>> conflicts{}; + /** @brief Newest first, as TrackingModel::failuresOf orders them. */ + Nui::Observed>> failures{}; + std::shared_ptr> conflictCount{std::make_shared>(0u)}; + std::shared_ptr> failureCount{std::make_shared>(0u)}; + }; + + /** + * @brief What the views show of one orphaned copy. The store owns it while the copy is listed. + */ + struct OrphanItem + { + explicit OrphanItem(Ids::InstanceId instanceId) + : instanceId{std::move(instanceId)} + {} + + Ids::InstanceId instanceId; + /** @brief The copy as the tracker listed it last. */ + Nui::Observed copy{}; + Nui::Observed name{}; + /** @brief Its size, the outliner side text. */ + Nui::Observed sideText{}; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp new file mode 100644 index 00000000..a14b7832 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The state of the File Tracking panel of one session: the TrackingModel and its projection onto the + * observed items the views read, plus the selection, the detail tab, the filter and the folded groups. + * TrackerClient feeds it, the views read it, TrackingActions change it. No setter syncs; the entry point + * (an event handler, an RPC reply) syncs once. + */ + class TrackingStore + { + public: + explicit TrackingStore(FileTrackingTexts texts); + ROAR_PIMPL_SPECIAL_FUNCTIONS_NO_MOVE(TrackingStore); + + /** + * @brief Adds or updates the item of @p entry, moving it between groups as its status changes. + */ + void showEntry(SharedData::TrackedEntry const& entry); + + /** + * @brief Drops the item of @p watchId, selecting nothing when it was selected. + */ + void removeEntry(Ids::WatchId const& watchId); + + /** + * @brief Adds or replaces @p conflict in its watch's list and updates the watch's counts, status and group. + */ + void showConflict(SharedData::WatchConflict const& conflict); + + /** + * @brief Drops the conflict @p conflictId from its watch's list and updates the watch. + */ + void removeConflict(Ids::ConflictId const& conflictId); + + /** + * @brief Adds or replaces @p failure in its watch's list and updates the watch's counts, status and group. + */ + void showFailure(SharedData::UploadFailure const& failure); + + /** + * @brief Drops the failure @p failureId from its watch's list and updates the watch. + */ + void removeFailure(Ids::FailureId const& failureId); + + /** + * @brief Updates the progress, the bars, the in-flight rows, the ring and the status of the watch @p progress + * names. + */ + void showWatchProgress(SharedData::WatchProgress const& progress); + + /** + * @brief Replaces the orphan items, selecting nothing when the selected orphan is gone. + */ + void showOrphanedCopies(SharedData::OrphanedCopies const& copies); + + /** + * @brief Forgets every item and the selection, the session ended. The filter and the folded groups stay. + */ + void clear(); + + /** + * @return What the tracker sent, for lookups by the facade (the remote path for Open Again, a copy's name for + * a confirmation). + */ + TrackingModel const& model() const; + + /** + * @return The translated texts the store formats with, for views that format the same way. + */ + FileTrackingTexts const& texts() const; + + /** + * @return The items of @p group that pass the filter, in TrackingModel::members order. For Orphaned the list + * stays empty, its items are @ref orphanMembers. + */ + Nui::Observed>>& membersOf(OutlinerGroup group); + + /** + * @return The orphan items that pass the filter, in TrackingModel::members order. + */ + Nui::Observed>>& orphanMembers(); + + /** + * @return The number of members @p group shows, for its count badge and for hiding it while empty. + */ + std::shared_ptr> countOf(OutlinerGroup group); + + /** + * @return The item of @p watchId, nullptr when it is not known. Kept while filtered out. + */ + std::shared_ptr watchItem(Ids::WatchId const& watchId) const; + + /** + * @return The item of @p instanceId, nullptr when it is not listed. Kept while filtered out. + */ + std::shared_ptr orphanItem(Ids::InstanceId const& instanceId) const; + + /** + * @return What the detail pane shows. + */ + Nui::Observed& selection(); + + /** + * @brief Shows @p selection in the detail pane. + */ + void select(Selection selection); + + /** + * @brief Moves the selection through the members of the expanded groups in display order; Previous and Next + * stop at the ends and start at the first or last member while nothing is selected. + */ + void moveSelection(SelectionMove move); + + /** + * @return The tab the watch detail shows, the same for every watch. Starts at Conflicts. + */ + DetailTab detailTab() const; + + /** + * @brief Shows @p tab in the watch detail, also updating @ref detailTabKey. + */ + void setDetailTab(DetailTab tab); + + /** + * @return The data-tab-key of the detail tab (the DetailTab name), the selected key of the tab strip. + */ + std::shared_ptr> detailTabKey(); + + /** + * @return The text of the outliner filter. + */ + Nui::Observed& filter(); + + /** + * @brief Filters the outliner by @p filter, see matchesFilter. Keeps the selection even when filtered out. + */ + void setFilter(std::string filter); + + /** + * @return Whether @p group is unfolded. + */ + bool groupExpanded(OutlinerGroup group) const; + + /** + * @brief Remembers whether @p group is unfolded, for @ref moveSelection. Every group starts unfolded. + */ + void setGroupExpanded(OutlinerGroup group, bool expanded); + + /** + * @brief Selects the watch of the newest conflict and its Conflicts tab. + * @return Whether there was a conflict to show. + */ + bool focusNewestConflict(); + + /** + * @brief Calls @p onChanged with the conflict and failure counts of every watch after each change of them, so + * the dock tab can follow. Replaces an earlier callback. + */ + void onAttentionChanged(std::function onChanged); + + private: + struct Implementation; + std::unique_ptr impl_; + }; +} diff --git a/frontend/include/frontend/session_components/file_tracking/translated_texts.hpp b/frontend/include/frontend/session_components/file_tracking/translated_texts.hpp new file mode 100644 index 00000000..bf3f0adc --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/translated_texts.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace FileTracking +{ + /** + * @brief The texts of the pure parts in the current language, from the "fileTracking" group of the language files. + * WASM only: the pure headers never include the language system. + */ + FileTrackingTexts translatedTexts(); +} diff --git a/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp b/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp new file mode 100644 index 00000000..1c79ce82 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp @@ -0,0 +1,36 @@ +#include + +#include + +namespace FileTracking +{ + struct DockTabAttention::Implementation + { + explicit Implementation(std::string panelId) + : panelId{std::move(panelId)} + {} + + std::string panelId; + AttentionState state{}; + }; + + DockTabAttention::DockTabAttention(std::string panelId) + : impl_{std::make_unique(std::move(panelId))} + {} + + ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(DockTabAttention); + + void DockTabAttention::show([[maybe_unused]] AttentionCounts const& counts) + {} + + bool DockTabAttention::unseen() const + { + return impl_->state.unseen(); + } + + void DockTabAttention::acknowledge() + {} + + void DockTabAttention::reset() + {} +} diff --git a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp new file mode 100644 index 00000000..0c9d0776 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp @@ -0,0 +1,117 @@ +#include +#include + +#include + +namespace FileTracking +{ + struct TrackerClient::Implementation + { + Implementation(TrackingStore& store, std::function onEnded) + : store{&store} + , onEnded{std::move(onEnded)} + {} + + TrackingStore* store; + std::function onEnded; + Ids::SessionId sessionId{}; + }; + + TrackerClient::TrackerClient( + TrackingStore& store, + std::function onEnded + ) + : impl_{std::make_unique(store, std::move(onEnded))} + {} + + ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(TrackerClient); + + void TrackerClient::activate([[maybe_unused]] Ids::SessionId sessionId) + {} + + void TrackerClient::deactivate() + {} + + void TrackerClient::open( + [[maybe_unused]] Paths::RemotePath const& remotePath, + [[maybe_unused]] std::uint64_t sizeBytes, + [[maybe_unused]] bool watch, + [[maybe_unused]] std::function + onOpened, + [[maybe_unused]] std::function onRefused + ) + {} + + void TrackerClient::checkUploadAndWatch( + [[maybe_unused]] Paths::LocalPath const& item, + [[maybe_unused]] Paths::RemotePath const& directory, + [[maybe_unused]] std::function onWatchable, + [[maybe_unused]] std::function onRefused + ) + {} + + void TrackerClient::uploadAndWatch( + [[maybe_unused]] Paths::LocalPath const& item, + [[maybe_unused]] Paths::RemotePath const& remotePath, + [[maybe_unused]] std::function onRefused + ) + {} + + void TrackerClient::discard( + [[maybe_unused]] Ids::InstanceId const& instanceId, + [[maybe_unused]] Paths::LocalPath const& localPath + ) + {} + + void TrackerClient::stop([[maybe_unused]] Ids::WatchId const& watchId) + {} + + void TrackerClient::setOption( + [[maybe_unused]] Ids::WatchId const& watchId, + [[maybe_unused]] SharedData::WatchOption option, + [[maybe_unused]] bool enabled + ) + {} + + void TrackerClient::answerConflict( + [[maybe_unused]] Ids::ConflictId const& conflictId, + [[maybe_unused]] SharedData::ConflictResolution resolution + ) + {} + + void TrackerClient::answerAllConflicts( + [[maybe_unused]] Ids::WatchId const& watchId, + [[maybe_unused]] SharedData::ConflictResolution resolution + ) + {} + + void TrackerClient::retryFailure([[maybe_unused]] Ids::FailureId const& failureId) + {} + + void TrackerClient::dismissFailure([[maybe_unused]] Ids::FailureId const& failureId) + {} + + void TrackerClient::retryAllFailures([[maybe_unused]] Ids::WatchId const& watchId) + {} + + void TrackerClient::dismissAllFailures([[maybe_unused]] Ids::WatchId const& watchId) + {} + + void TrackerClient::removeStopped([[maybe_unused]] Ids::WatchId const& watchId) + {} + + void TrackerClient::removeAllStopped() + {} + + void TrackerClient::listOrphanedCopies() + {} + + void TrackerClient::removeOrphanedCopy( + [[maybe_unused]] Ids::InstanceId const& instanceId, + [[maybe_unused]] std::function onFailed + ) + {} + + void TrackerClient::removeAllOrphanedCopies() + {} +} diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp new file mode 100644 index 00000000..40e93e03 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp @@ -0,0 +1,162 @@ +#include +#include + +#include +#include +#include + +namespace FileTracking +{ + struct TrackingStore::Implementation + { + explicit Implementation(FileTrackingTexts texts) + : texts{std::move(texts)} + {} + + FileTrackingTexts texts; + TrackingModel model{}; + std::array>>, outlinerGroupOrder.size()> members{}; + Nui::Observed>> orphans{}; + std::array>, outlinerGroupOrder.size()> counts{ + std::make_shared>(0u), + std::make_shared>(0u), + std::make_shared>(0u), + std::make_shared>(0u), + std::make_shared>(0u), + }; + Nui::Observed selection{}; + DetailTab detailTab{DetailTab::Conflicts}; + std::shared_ptr> detailTabKey{ + std::make_shared>(Utility::enumToString(DetailTab::Conflicts)) + }; + Nui::Observed filter{}; + std::array expanded{true, true, true, true, true}; + std::function onAttentionChanged{}; + }; + + namespace + { + std::size_t indexOf(OutlinerGroup group) + { + return static_cast(group); + } + } + + TrackingStore::TrackingStore(FileTrackingTexts texts) + : impl_{std::make_unique(std::move(texts))} + {} + + ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(TrackingStore); + + void TrackingStore::showEntry([[maybe_unused]] SharedData::TrackedEntry const& entry) + {} + + void TrackingStore::removeEntry([[maybe_unused]] Ids::WatchId const& watchId) + {} + + void TrackingStore::showConflict([[maybe_unused]] SharedData::WatchConflict const& conflict) + {} + + void TrackingStore::removeConflict([[maybe_unused]] Ids::ConflictId const& conflictId) + {} + + void TrackingStore::showFailure([[maybe_unused]] SharedData::UploadFailure const& failure) + {} + + void TrackingStore::removeFailure([[maybe_unused]] Ids::FailureId const& failureId) + {} + + void TrackingStore::showWatchProgress([[maybe_unused]] SharedData::WatchProgress const& progress) + {} + + void TrackingStore::showOrphanedCopies([[maybe_unused]] SharedData::OrphanedCopies const& copies) + {} + + void TrackingStore::clear() + {} + + TrackingModel const& TrackingStore::model() const + { + return impl_->model; + } + + FileTrackingTexts const& TrackingStore::texts() const + { + return impl_->texts; + } + + Nui::Observed>>& TrackingStore::membersOf(OutlinerGroup group) + { + return impl_->members[indexOf(group)]; + } + + Nui::Observed>>& TrackingStore::orphanMembers() + { + return impl_->orphans; + } + + std::shared_ptr> TrackingStore::countOf(OutlinerGroup group) + { + return impl_->counts[indexOf(group)]; + } + + std::shared_ptr TrackingStore::watchItem([[maybe_unused]] Ids::WatchId const& watchId) const + { + return nullptr; + } + + std::shared_ptr TrackingStore::orphanItem([[maybe_unused]] Ids::InstanceId const& instanceId) const + { + return nullptr; + } + + Nui::Observed& TrackingStore::selection() + { + return impl_->selection; + } + + void TrackingStore::select([[maybe_unused]] Selection selection) + {} + + void TrackingStore::moveSelection([[maybe_unused]] SelectionMove move) + {} + + DetailTab TrackingStore::detailTab() const + { + return impl_->detailTab; + } + + void TrackingStore::setDetailTab([[maybe_unused]] DetailTab tab) + {} + + std::shared_ptr> TrackingStore::detailTabKey() + { + return impl_->detailTabKey; + } + + Nui::Observed& TrackingStore::filter() + { + return impl_->filter; + } + + void TrackingStore::setFilter([[maybe_unused]] std::string filter) + {} + + bool TrackingStore::groupExpanded(OutlinerGroup group) const + { + return impl_->expanded[indexOf(group)]; + } + + void TrackingStore::setGroupExpanded([[maybe_unused]] OutlinerGroup group, [[maybe_unused]] bool expanded) + {} + + bool TrackingStore::focusNewestConflict() + { + return false; + } + + void TrackingStore::onAttentionChanged(std::function onChanged) + { + impl_->onAttentionChanged = std::move(onChanged); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/translated_texts.cpp b/frontend/source/frontend/session_components/file_tracking/translated_texts.cpp new file mode 100644 index 00000000..155ba269 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/translated_texts.cpp @@ -0,0 +1,56 @@ +#include +#include + +namespace FileTracking +{ + FileTrackingTexts translatedTexts() + { + return { + .counts = + { + .conflictOne = language->get("fileTracking", "conflictCountOne"), + .conflicts = language->get("fileTracking", "conflictCount"), + .failed = language->get("fileTracking", "failedCount"), + }, + .stopSides = + { + .stoppedByUser = language->get("fileTracking", "sideStoppedByUser"), + .overlap = language->get("fileTracking", "sideOverlap"), + .copyGone = language->get("fileTracking", "sideCopyGone"), + .connectionLost = language->get("fileTracking", "sideConnectionLost"), + }, + .statuses = + { + .syncing = language->get("fileTracking", "groupSyncing"), + .upToDate = language->get("fileTracking", "statusUpToDate"), + .stopped = language->get("fileTracking", "groupStopped"), + }, + .stopReasons = + { + .stoppedByUser = language->get("fileTracking", "stopReasonStoppedByUser"), + .overlap = language->get("fileTracking", "stopReasonOverlap"), + .overlapUnknown = language->get("fileTracking", "stopReasonOverlapUnknown"), + .copyGone = language->get("fileTracking", "stopReasonCopyGone"), + .connectionLost = language->get("fileTracking", "stopReasonConnectionLost"), + }, + .failureReasons = + { + .lockedLocally = language->get("fileTracking", "reasonLockedLocally"), + .permissionDeniedLocally = language->get("fileTracking", "reasonPermissionDeniedLocally"), + .unreadableLocally = language->get("fileTracking", "reasonUnreadableLocally"), + .permissionDeniedOnServer = language->get("fileTracking", "reasonPermissionDeniedOnServer"), + .noSpaceOnServer = language->get("fileTracking", "reasonNoSpaceOnServer"), + .serverRefused = language->get("fileTracking", "reasonServerRefused"), + .connectionProblem = language->get("fileTracking", "reasonConnectionProblem"), + .notQueued = language->get("fileTracking", "reasonNotQueued"), + .unknown = language->get("fileTracking", "reasonUnknown"), + }, + .conflictLines = { + .changedOnServer = language->get("fileTracking", "conflictChangedOnServer"), + .deletedOnServer = language->get("fileTracking", "conflictDeletedOnServer"), + .createdOnServer = language->get("fileTracking", "conflictCreatedOnServer"), + .timeUnknown = language->get("fileTracking", "timeUnknown"), + }, + }; + } +} From 06e1c00c4da83fd30dd0598735156a7cf345760e Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:11:23 +0200 Subject: [PATCH 031/114] Declared the file tracking views as stubs and added the new sources to CMake --- .../file_tracking/conflicts_tab.hpp | 22 ++++++++ .../file_tracking/detail_tabs.hpp | 54 +++++++++++++++++++ .../file_tracking/failures_tab.hpp | 23 ++++++++ .../file_tracking/item_list_box.hpp | 40 ++++++++++++++ .../file_tracking/options_tab.hpp | 22 ++++++++ .../file_tracking/orphan_detail.hpp | 22 ++++++++ .../file_tracking/orphan_row.hpp | 21 ++++++++ .../file_tracking/outliner.hpp | 21 ++++++++ .../file_tracking/status_glyph.hpp | 20 +++++++ .../file_tracking/stop_reason_strip.hpp | 21 ++++++++ .../file_tracking/sync_progress_block.hpp | 23 ++++++++ .../file_tracking/watch_detail.hpp | 19 +++++++ .../file_tracking/watch_header.hpp | 23 ++++++++ .../file_tracking/watch_row.hpp | 22 ++++++++ frontend/source/frontend/CMakeLists.txt | 20 +++++++ .../file_tracking/conflicts_tab.cpp | 15 ++++++ .../file_tracking/detail_tabs.cpp | 20 +++++++ .../file_tracking/failures_tab.cpp | 15 ++++++ .../file_tracking/item_list_box.cpp | 12 +++++ .../file_tracking/options_tab.cpp | 15 ++++++ .../file_tracking/orphan_detail.cpp | 15 ++++++ .../file_tracking/orphan_row.cpp | 15 ++++++ .../file_tracking/outliner.cpp | 12 +++++ .../file_tracking/status_glyph.cpp | 16 ++++++ .../file_tracking/stop_reason_strip.cpp | 15 ++++++ .../file_tracking/sync_progress_block.cpp | 15 ++++++ .../file_tracking/watch_detail.cpp | 15 ++++++ .../file_tracking/watch_header.cpp | 15 ++++++ .../file_tracking/watch_row.cpp | 15 ++++++ 29 files changed, 583 insertions(+) create mode 100644 frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/failures_tab.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/item_list_box.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/options_tab.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/orphan_row.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/outliner.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/status_glyph.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/watch_detail.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/watch_header.hpp create mode 100644 frontend/include/frontend/session_components/file_tracking/watch_row.hpp create mode 100644 frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/failures_tab.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/item_list_box.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/options_tab.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/orphan_row.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/outliner.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/status_glyph.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/watch_detail.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/watch_header.cpp create mode 100644 frontend/source/frontend/session_components/file_tracking/watch_row.cpp diff --git a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp new file mode 100644 index 00000000..e3164af7 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The Conflicts tab: an itemListBox counting item->conflictCount, with Replace All on Server + * (data-ft-action=replace-all, Warning) and Keep All Server's (keep-all) calling actions.answerAllConflicts, + * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts (data-ft-conflict-id): the + * file, the line, Replace on Server (replace, Warning) and Keep Server's (keep) calling + * actions.answerConflict. + */ + Nui::ElementRenderer + conflictsTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp b/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp new file mode 100644 index 00000000..8a4977fb --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief One tab of the watch detail. A new tab (Activity, later) is one more registry entry. + */ + struct DetailTabEntry + { + DetailTab tab{DetailTab::Conflicts}; + /** @brief The translated title, from a literal language key (tabConflicts, tabFailed, tabOptions). */ + std::string title{}; + /** @brief The count of the tab's badge for an item, nullptr or an empty function for no badge. */ + std::function>(WatchItem const& item)> count{}; + ScriptNuiComponents::StyleVariant badgeVariant{ScriptNuiComponents::StyleVariant::Regular}; + /** @brief Renders the tab's panel. */ + std::function const& item + )> + render{}; + }; + + /** + * @return The tabs in detailTabOrder: Conflicts (count item.conflictCount, Warning, conflictsTab), Failed + * (item.failureCount, Danger, failuresTab) and Options (no badge, optionsTab). + */ + std::vector detailTabRegistry(); + + /** + * @brief The tabStrip of the registry (data-tab-key=, aria-label detailTabsLabel) on + * store.detailTabKey(), calling actions.chooseTab, above the panel of the chosen tab. Tabs are the same + * for every watch and the chosen one stays when the selection changes. + */ + Nui::ElementRenderer + detailTabs(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp new file mode 100644 index 00000000..a982eb3a --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The Failed tab: an itemListBox counting item->failureCount, with Retry All (data-ft-action=retry-all, + * Primary) and Dismiss All (dismiss-all), the hint notRetriedAutomatically and the empty text noFailures. One row + * per item->failures (data-ft-failure-id, data-ft-reason=): the file, the reason (its title + * the detail when there is one, plus failedTimes after more than one attempt), the time, Retry (retry, + * actions.retryFailure) and a dismiss icon button (dismiss, title dismiss, aria-label dismissFailureLabel, + * actions.dismissFailure). + */ + Nui::ElementRenderer + failuresTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp new file mode 100644 index 00000000..4c0f8448 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The options of @ref itemListBox, for designated initializers in this order. Every field has a default. + */ + struct ItemListBoxOptions + { + /** @brief The buttons at the start of the toolbar above the box. */ + std::optional actions = std::nullopt; + /** @brief A muted hint at the end of the toolbar. */ + std::string hint = {}; + /** @brief Shown instead of the toolbar and the box while @ref count is 0. */ + std::string emptyText = {}; + /** @brief The number of items, data-empty is on the root while it is 0. A null pointer counts as not empty. */ + std::shared_ptr> count = {}; + /** @brief Merged onto the root, for example data-class="conflicts" or "failures" for the box colours. */ + std::vector attributes = {}; + }; + + /** + * @brief A toolbar above a box that scrolls on its own, or an empty text instead of both. Markup: + * div.file-tracking-item-list-box[data-empty] > div.file-tracking-item-list-box-toolbar (actions, then + * span.file-tracking-item-list-box-hint), div.file-tracking-item-list-box-items (@p content) and + * div.file-tracking-item-list-box-empty (emptyText). Kept in the host until a second user (the queue's + * Failed tab) adopts it. + * @param content The rows, usually an element holding a Nui::range. + */ + Nui::ElementRenderer itemListBox(ItemListBoxOptions options, Nui::ElementRenderer content); +} diff --git a/frontend/include/frontend/session_components/file_tracking/options_tab.hpp b/frontend/include/frontend/session_components/file_tracking/options_tab.hpp new file mode 100644 index 00000000..235a53c1 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/options_tab.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The Options tab: one switch_ row per SharedData::WatchOption (data-ft-option=) with its label + * (columnAutoReupload, columnMoveRemote, columnDeleteRemote, columnCheckRemote) and description + * (option...Description), calling actions.setOption. On a copy MoveRemoteOnLocalMove and + * DeleteRemoteOnLocalDelete are off and disabled, described by optionNeverForCopy with the title + * downloadedCopyOptionHint, which also shows below the switches. + */ + Nui::ElementRenderer + optionsTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp b/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp new file mode 100644 index 00000000..18a5245b --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The detail pane of an orphaned copy (data-ft-detail=orphan, data-ft-instance-id): its glyph, name and kind + * label orphanKind, the actions Show Folder (data-ft-action=show-folder, actions.openLocally on its + * directory) and Delete Copy (delete-orphan, Danger, actions.deleteOrphanedCopy, which confirms first), the lines + * orphanDownloadedFrom or orphanOriginUnknown, orphanDownloadedAt, orphanEndedAt and orphanSize, and + * orphanExplanation. + */ + Nui::ElementRenderer + orphanDetail(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp b/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp new file mode 100644 index 00000000..f13ff4a3 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The outliner row of an orphaned copy: a listRow (data-ft-instance-id, data-ft-name) led by orphanGlyph, + * with the size item->sideText beside it and a trash button (data-ft-action=delete-orphan, title + * deleteOrphanTooltip) calling actions.deleteOrphanedCopy. Selected while store.selection() holds its + * instance; activating it calls actions.select. + */ + Nui::ElementRenderer + orphanRow(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/outliner.hpp b/frontend/include/frontend/session_components/file_tracking/outliner.hpp new file mode 100644 index 00000000..7c5d3f88 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/outliner.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include + +namespace FileTracking +{ + /** + * @brief The master list of the panel: the filter textInput (data-ft-filter, placeholder filterPlaceholder) above a + * role=listbox (aria-label outlinerLabel) holding one collapsibleSection per group of outlinerGroupOrder + * (data-ft-group=). Each section lists store.membersOf(group) with watchRow, Orphaned lists + * store.orphanMembers() with orphanRow. Its headerEnd holds countBadge(store.countOf(group)); Stopped adds a + * trash button (data-ft-action=remove-all-stopped, actions.removeAllStopped) and Orphaned one + * (data-ft-action=delete-all-orphans, actions.deleteAllOrphanedCopies). A group without members stays + * mounted and gets data-hidden. Up, Down, Home and End on the list call actions.moveSelection; folding a group + * calls actions.setGroupExpanded; typing calls actions.filter. + */ + Nui::ElementRenderer outliner(TrackingStore& store, TrackingActions const& actions); +} diff --git a/frontend/include/frontend/session_components/file_tracking/status_glyph.hpp b/frontend/include/frontend/session_components/file_tracking/status_glyph.hpp new file mode 100644 index 00000000..6acbd97b --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/status_glyph.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The icon that leads an outliner row that shows no ring: a check for Watching, a question for Conflicts, a + * warning triangle for Failed, a square for Stopped (Syncing, normally shown as a ring, gets the check). A + * span.file-tracking-glyph[data-status=] around a Ui5 icon, coloured by CSS. + */ + Nui::ElementRenderer statusGlyph(WatchStatus status); + + /** + * @brief The file icon that leads an orphan row, span.file-tracking-glyph[data-status=Orphaned]. + */ + Nui::ElementRenderer orphanGlyph(); +} diff --git a/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp new file mode 100644 index 00000000..0f1f4ef2 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief Why a watch stopped: a messageStrip (data-ft-reason=) with item->stopText, mounted always + * and hidden while the watch runs. Its actions slot holds Show “Y” (data-ft-action=show-other-watch, text + * showOtherWatch, actions.showWatch) for an Overlap stop that names the other watch, and Open Again + * (open-again, actions.openAgain) for any other stopped copy. Stopped originals get no action. + */ + Nui::ElementRenderer + stopReasonStrip(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp new file mode 100644 index 00000000..b7207555 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The pinned progress of a watch (aria-label progressLabel). While Syncing: progressFiles and + * progressWaiting (plus progressWaitingForAnswer while answers are due), a total progressBar in Bytes mode on + * item->bytesDone and item->bytesTotal (data-ft-progress-total), and per row of item->inFlight its name and a small + * progressBar (data-ft-progress-file=). Otherwise a summary line (statusUpToDate, + * progressUpToDateLastUpload, progressStoppedLastUpload, progressConflictsWait(One) or progressFilesUpToDate) and, + * after a sync, a bar of the last sync. + */ + Nui::ElementRenderer + syncProgressBlock(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp b/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp new file mode 100644 index 00000000..83eed467 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The detail pane of a watch (data-ft-detail=watch, data-ft-watch-id): watchHeader, stopReasonStrip, + * syncProgressBlock and detailTabs, top to bottom. + */ + Nui::ElementRenderer + watchDetail(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/watch_header.hpp b/frontend/include/frontend/session_components/file_tracking/watch_header.hpp new file mode 100644 index 00000000..c22dc28e --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/watch_header.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The head of the watch detail: the kind icon, the name, the kind label + * (fileExplorer.contextMenu.openAndWatch for copies, uploadAndWatch for originals), the status badge + * (data-ft-status=, text item->statusText) and the actions. A running watch offers Open Local Item + * (data-ft-action=open-local, actions.openLocally), Reveal on Server (reveal-on-server, actions.revealOnServer) and + * Stop (stop, actions.stop); a stopped one Open Local Item and Remove (remove, actions.removeStopped). Below, a + * muted line "local path -> remote path", the local part temporaryCopy for copies. + */ + Nui::ElementRenderer + watchHeader(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/include/frontend/session_components/file_tracking/watch_row.hpp b/frontend/include/frontend/session_components/file_tracking/watch_row.hpp new file mode 100644 index 00000000..9e5af0ea --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/watch_row.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @brief The outliner row of a watch: a listRow (data-ft-watch-id, data-ft-name, data-ft-state=) led + * by a progressRing on item->ringFraction while the watch is Syncing (label syncingTooltip), by statusGlyph + * otherwise. Its side text is item->sideText; it is selected while store.selection() holds its watch and + * activating it calls actions.select. A stopped row gets a trash button (data-ft-action=remove-stopped, + * title removeStoppedTooltip) calling actions.removeStopped. + */ + Nui::ElementRenderer + watchRow(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); +} diff --git a/frontend/source/frontend/CMakeLists.txt b/frontend/source/frontend/CMakeLists.txt index fa02c681..2cb91ec8 100644 --- a/frontend/source/frontend/CMakeLists.txt +++ b/frontend/source/frontend/CMakeLists.txt @@ -139,6 +139,26 @@ target_sources( session_components/command_history_panel.cpp session_components/command_snippets_panel.cpp session_components/file_tracking.cpp + session_components/file_tracking/conflicts_tab.cpp + session_components/file_tracking/detail_tabs.cpp + session_components/file_tracking/dock_tab_attention.cpp + session_components/file_tracking/failures_tab.cpp + session_components/file_tracking/file_tracking_texts.cpp + session_components/file_tracking/item_list_box.cpp + session_components/file_tracking/options_tab.cpp + session_components/file_tracking/orphan_detail.cpp + session_components/file_tracking/orphan_row.cpp + session_components/file_tracking/outliner.cpp + session_components/file_tracking/status_glyph.cpp + session_components/file_tracking/stop_reason_strip.cpp + session_components/file_tracking/sync_progress_block.cpp + session_components/file_tracking/tracker_client.cpp + session_components/file_tracking/tracking_model.cpp + session_components/file_tracking/tracking_store.cpp + session_components/file_tracking/translated_texts.cpp + session_components/file_tracking/watch_detail.cpp + session_components/file_tracking/watch_header.cpp + session_components/file_tracking/watch_row.cpp session_components/session_options.cpp session_components/operation_queue.cpp session_components/local_operation_router.cpp diff --git a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp new file mode 100644 index 00000000..aafee768 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer conflictsTab( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp b/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp new file mode 100644 index 00000000..318140ec --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp @@ -0,0 +1,20 @@ +#include + +#include + +namespace FileTracking +{ + std::vector detailTabRegistry() + { + return {}; + } + + Nui::ElementRenderer detailTabs( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp new file mode 100644 index 00000000..1bbd9c1c --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer failuresTab( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp new file mode 100644 index 00000000..0e26bd63 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp @@ -0,0 +1,12 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer + itemListBox([[maybe_unused]] ItemListBoxOptions options, [[maybe_unused]] Nui::ElementRenderer content) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/options_tab.cpp b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp new file mode 100644 index 00000000..c30a5da0 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer optionsTab( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp new file mode 100644 index 00000000..32291d40 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer orphanDetail( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp b/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp new file mode 100644 index 00000000..71f0345e --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer orphanRow( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/outliner.cpp b/frontend/source/frontend/session_components/file_tracking/outliner.cpp new file mode 100644 index 00000000..de6b5a4f --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/outliner.cpp @@ -0,0 +1,12 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer + outliner([[maybe_unused]] TrackingStore& store, [[maybe_unused]] TrackingActions const& actions) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp new file mode 100644 index 00000000..fe4beaa3 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp @@ -0,0 +1,16 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer statusGlyph([[maybe_unused]] WatchStatus status) + { + return Nui::nil(); + } + + Nui::ElementRenderer orphanGlyph() + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp new file mode 100644 index 00000000..7c752e46 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer stopReasonStrip( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp new file mode 100644 index 00000000..1971ff8b --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer syncProgressBlock( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp new file mode 100644 index 00000000..696ec542 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer watchDetail( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp new file mode 100644 index 00000000..4f167fad --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer watchHeader( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} diff --git a/frontend/source/frontend/session_components/file_tracking/watch_row.cpp b/frontend/source/frontend/session_components/file_tracking/watch_row.cpp new file mode 100644 index 00000000..b8591092 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/watch_row.cpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace FileTracking +{ + Nui::ElementRenderer watchRow( + [[maybe_unused]] TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + [[maybe_unused]] std::shared_ptr const& item + ) + { + return Nui::nil(); + } +} From 693a076b90619761e4a9d838a6bf87ea2d75557d Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:12:30 +0200 Subject: [PATCH 032/114] Wired the new library styles, empty file tracking styles and the badge tab module --- _cmake/copy_styles_to_build_dir.cmake | 20 +++++++ frontend/source/frontend/CMakeLists.txt | 12 ++++ .../content_panels/badge_tab_renderer.ts | 1 + static/styles/dock_tab_attention.css | 0 static/styles/file_tracking_detail.css | 0 static/styles/file_tracking_outliner.css | 0 static/styles/file_tracking_tabs.css | 0 static/styles/main.css | 57 +++++++++++++++++++ 8 files changed, 90 insertions(+) create mode 100644 static/source/content_panels/badge_tab_renderer.ts create mode 100644 static/styles/dock_tab_attention.css create mode 100644 static/styles/file_tracking_detail.css create mode 100644 static/styles/file_tracking_outliner.css create mode 100644 static/styles/file_tracking_tabs.css diff --git a/_cmake/copy_styles_to_build_dir.cmake b/_cmake/copy_styles_to_build_dir.cmake index 738835c0..c3188921 100644 --- a/_cmake/copy_styles_to_build_dir.cmake +++ b/_cmake/copy_styles_to_build_dir.cmake @@ -24,6 +24,11 @@ if (NOT OMIT_FRONTEND_BUILD) "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/collapsible_section.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/checkbox.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/toast.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/progress_bar.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/progress_ring.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/badge.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/tab_strip.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/list_row.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/themes/light.css" COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/styles/nui-file-explorer" COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/styles/5cript-nui-components" @@ -51,6 +56,11 @@ if (NOT OMIT_FRONTEND_BUILD) COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/collapsible_section.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/collapsible_section.css" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/checkbox.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/checkbox.css" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/toast.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/toast.css" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/progress_bar.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/progress_bar.css" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/progress_ring.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/progress_ring.css" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/badge.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/badge.css" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/tab_strip.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/tab_strip.css" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/list_row.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/list_row.css" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/themes/light.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/themes/light.css" DEPENDS "${CMAKE_SOURCE_DIR}/nui-file-explorer/styles/file_grid.css" @@ -76,6 +86,11 @@ if (NOT OMIT_FRONTEND_BUILD) "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/collapsible_section.css" "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/checkbox.css" "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/toast.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/progress_bar.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/progress_ring.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/badge.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/tab_strip.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/list_row.css" "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/themes/light.css" ) @@ -105,6 +120,11 @@ if (NOT OMIT_FRONTEND_BUILD) "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/collapsible_section.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/checkbox.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/toast.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/progress_bar.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/progress_ring.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/badge.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/tab_strip.css" + "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/list_row.css" "${CMAKE_BINARY_DIR}/styles/5cript-nui-components/themes/light.css" ) endif() \ No newline at end of file diff --git a/frontend/source/frontend/CMakeLists.txt b/frontend/source/frontend/CMakeLists.txt index 2cb91ec8..cf10f9fc 100644 --- a/frontend/source/frontend/CMakeLists.txt +++ b/frontend/source/frontend/CMakeLists.txt @@ -301,6 +301,10 @@ nui_prepare_emscripten_target( "${CMAKE_SOURCE_DIR}/static/styles/local_shell_tab.css" "${CMAKE_SOURCE_DIR}/static/styles/terminal_toolbar.css" "${CMAKE_SOURCE_DIR}/static/styles/file_tracking_panel.css" + "${CMAKE_SOURCE_DIR}/static/styles/file_tracking_outliner.css" + "${CMAKE_SOURCE_DIR}/static/styles/file_tracking_detail.css" + "${CMAKE_SOURCE_DIR}/static/styles/file_tracking_tabs.css" + "${CMAKE_SOURCE_DIR}/static/styles/dock_tab_attention.css" "${CMAKE_SOURCE_DIR}/static/styles/command_history_panel.css" "${CMAKE_SOURCE_DIR}/static/styles/command_snippets_panel.css" "${CMAKE_SOURCE_DIR}/static/styles/code_editor.css" @@ -350,8 +354,16 @@ nui_prepare_emscripten_target( "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/tag_box.css" "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/collapsible_section.css" "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/checkbox.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/progress_bar.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/progress_ring.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/badge.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/tab_strip.css" + "${CMAKE_SOURCE_DIR}/dependencies/5cript-nui-components/styles/list_row.css" "${CMAKE_SOURCE_DIR}/static/source/content_panels/command_history.ts" "${CMAKE_SOURCE_DIR}/static/source/content_panels/command_snippets.ts" + "${CMAKE_SOURCE_DIR}/static/source/content_panels/file_tracking.ts" + "${CMAKE_SOURCE_DIR}/static/source/content_panels/badge_tab_renderer.ts" + "${CMAKE_SOURCE_DIR}/static/source/content_panel.ts" "${CMAKE_SOURCE_DIR}/static/source/code_editor.ts" ) diff --git a/static/source/content_panels/badge_tab_renderer.ts b/static/source/content_panels/badge_tab_renderer.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/static/source/content_panels/badge_tab_renderer.ts @@ -0,0 +1 @@ +export {}; diff --git a/static/styles/dock_tab_attention.css b/static/styles/dock_tab_attention.css new file mode 100644 index 00000000..e69de29b diff --git a/static/styles/file_tracking_detail.css b/static/styles/file_tracking_detail.css new file mode 100644 index 00000000..e69de29b diff --git a/static/styles/file_tracking_outliner.css b/static/styles/file_tracking_outliner.css new file mode 100644 index 00000000..e69de29b diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css new file mode 100644 index 00000000..e69de29b diff --git a/static/styles/main.css b/static/styles/main.css index 73cf7138..93b99d40 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -11,6 +11,10 @@ @import "./licenses.css"; @import "./notification_log.css"; @import "./file_tracking_panel.css"; +@import "./file_tracking_outliner.css"; +@import "./file_tracking_detail.css"; +@import "./file_tracking_tabs.css"; +@import "./dock_tab_attention.css"; @import "./command_history_panel.css"; @import "./command_snippets_panel.css"; @import "./code_editor.css"; @@ -50,6 +54,11 @@ @import "../../../styles/5cript-nui-components/collapsible_section.css"; @import "../../../styles/5cript-nui-components/checkbox.css"; @import "../../../styles/5cript-nui-components/toast.css"; +@import "../../../styles/5cript-nui-components/progress_bar.css"; +@import "../../../styles/5cript-nui-components/progress_ring.css"; +@import "../../../styles/5cript-nui-components/badge.css"; +@import "../../../styles/5cript-nui-components/tab_strip.css"; +@import "../../../styles/5cript-nui-components/list_row.css"; *, *::before, @@ -159,6 +168,30 @@ --script-nui-components-button-color-primary: color-mix(in srgb, var(--theme-color) 22%, black 78%); --script-nui-components-button-border-color-primary: color-mix(in srgb, var(--theme-color) 55%, black 45%); + /* Badge */ + --script-nui-components-badge-warning-background: var(--script-nui-components-toast-warning, #e0a02e); + --script-nui-components-badge-danger-background: var(--script-nui-components-toast-error, #d9534f); + + /* Progress bar and ring */ + --script-nui-components-progress-bar-theme: var(--theme-color); + --script-nui-components-progress-ring-theme: var(--theme-color); + --script-nui-components-progress-ring-track: var(--border-color-fields); + + /* Tab strip */ + --script-nui-components-tab-strip-color: var(--color-secondary); + --script-nui-components-tab-strip-color-selected: var(--color); + --script-nui-components-tab-strip-accent-color: var(--theme-color); + --script-nui-components-tab-strip-hover-background: color-mix(in srgb, var(--color) 6%, transparent); + --script-nui-components-tab-strip-border-color: var(--border-color-fields); + + /* List row */ + --script-nui-components-list-row-color: var(--color); + --script-nui-components-list-row-side-color: var(--color-secondary); + --script-nui-components-list-row-hover-background: color-mix(in srgb, var(--color) 5%, transparent); + --script-nui-components-list-row-selected-background: color-mix(in srgb, var(--theme-color) 22%, var(--brightness-mixin) 78%); + --script-nui-components-list-row-selected-color: var(--color); + --script-nui-components-list-row-focus-color: var(--theme-color); + --nui-file-grid-color: var(--color); --nui-file-grid-background-color: var(--background-color); --nui-file-grid-brightness-mixin: var(--brightness-mixin); @@ -257,6 +290,30 @@ --sync-progress-color: rgba(56, 142, 60, 0.35); --sync-done-color: rgba(56, 142, 60, 0.32); --sync-error-color: rgba(198, 40, 40, 0.32); + + /* Badge, progress, tab strip and list row: repeated so the host colors win over light.css */ + --script-nui-components-badge-warning-background: var(--script-nui-components-toast-warning, #e0a02e); + --script-nui-components-badge-danger-background: var(--script-nui-components-toast-error, #d9534f); + + /* Progress bar and ring */ + --script-nui-components-progress-bar-theme: var(--theme-color); + --script-nui-components-progress-ring-theme: var(--theme-color); + --script-nui-components-progress-ring-track: var(--border-color-fields); + + /* Tab strip */ + --script-nui-components-tab-strip-color: var(--color-secondary); + --script-nui-components-tab-strip-color-selected: var(--color); + --script-nui-components-tab-strip-accent-color: var(--theme-color); + --script-nui-components-tab-strip-hover-background: color-mix(in srgb, var(--color) 6%, transparent); + --script-nui-components-tab-strip-border-color: var(--border-color-fields); + + /* List row */ + --script-nui-components-list-row-color: var(--color); + --script-nui-components-list-row-side-color: var(--color-secondary); + --script-nui-components-list-row-hover-background: color-mix(in srgb, var(--color) 5%, transparent); + --script-nui-components-list-row-selected-background: color-mix(in srgb, var(--theme-color) 22%, var(--brightness-mixin) 78%); + --script-nui-components-list-row-selected-color: var(--color); + --script-nui-components-list-row-focus-color: var(--theme-color); } .login-form>div { From 6e03cab726f4bba1e7eeac39343d03e37afc0da7 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:17:58 +0200 Subject: [PATCH 033/114] Aligned the continuation lines of the file tracking doc comments --- .../file_tracking/attention_state.hpp | 8 ++++---- .../file_tracking/conflicts_tab.hpp | 6 +++--- .../session_components/file_tracking/detail_tabs.hpp | 4 ++-- .../file_tracking/dock_tab_attention.hpp | 4 ++-- .../session_components/file_tracking/failures_tab.hpp | 10 +++++----- .../file_tracking/file_tracking_texts.hpp | 7 ++++--- .../file_tracking/orphan_detail.hpp | 4 ++-- .../session_components/file_tracking/orphan_row.hpp | 4 ++-- .../session_components/file_tracking/outliner.hpp | 4 ++-- .../file_tracking/stop_reason_strip.hpp | 2 +- .../file_tracking/sync_progress_block.hpp | 10 +++++----- .../file_tracking/tracker_client.hpp | 6 +++--- .../file_tracking/tracking_store.hpp | 8 ++++---- .../session_components/file_tracking/watch_header.hpp | 11 ++++++----- .../session_components/file_tracking/watch_row.hpp | 2 +- 15 files changed, 46 insertions(+), 44 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/attention_state.hpp b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp index ee768e03..e9a7b9b0 100644 --- a/frontend/include/frontend/session_components/file_tracking/attention_state.hpp +++ b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp @@ -29,8 +29,8 @@ namespace FileTracking }; /** - * @brief Decides when the dock tab glows and whether a click on it should jump to the newest conflict. Pure, - * tested natively; DockTabAttention feeds it and forwards its results to the tab. + * @brief Decides when the dock tab glows and whether a click on it should jump to the newest conflict. Pure, tested + * natively; DockTabAttention feeds it and forwards its results to the tab. */ class AttentionState { @@ -42,8 +42,8 @@ namespace FileTracking /** * @brief Takes the current counts, seen at @p now (any steady clock). More conflicts than before make them - * unseen and pulse, unless the last pulse began less than @ref pulseWindow ago. Failures never pulse. - * No conflicts left clears unseen. + * unseen and pulse, unless the last pulse began less than @ref pulseWindow ago. Failures never pulse. No + * conflicts left clears unseen. * @return What the tab shows now, nothing when neither the counts changed nor a pulse is due. */ std::optional diff --git a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp index e3164af7..a8339bd5 100644 --- a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp +++ b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp @@ -13,9 +13,9 @@ namespace FileTracking /** * @brief The Conflicts tab: an itemListBox counting item->conflictCount, with Replace All on Server * (data-ft-action=replace-all, Warning) and Keep All Server's (keep-all) calling actions.answerAllConflicts, - * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts (data-ft-conflict-id): the - * file, the line, Replace on Server (replace, Warning) and Keep Server's (keep) calling - * actions.answerConflict. + * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts (data-ft-conflict-id): + * the file, the line, Replace on Server (replace, Warning) and Keep Server's (keep) calling + * actions.answerConflict. */ Nui::ElementRenderer conflictsTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp b/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp index 8a4977fb..db550357 100644 --- a/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp +++ b/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp @@ -46,8 +46,8 @@ namespace FileTracking /** * @brief The tabStrip of the registry (data-tab-key=, aria-label detailTabsLabel) on - * store.detailTabKey(), calling actions.chooseTab, above the panel of the chosen tab. Tabs are the same - * for every watch and the chosen one stays when the selection changes. + * store.detailTabKey(), calling actions.chooseTab, above the panel of the chosen tab. Tabs are the same for + * every watch and the chosen one stays when the selection changes. */ Nui::ElementRenderer detailTabs(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp b/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp index 5e93998c..1e6b3443 100644 --- a/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp +++ b/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp @@ -13,8 +13,8 @@ namespace FileTracking * @brief Makes the File Tracking dock tab of a session count and glow. Feeds AttentionState with the steady clock * and forwards only changes, through Nui::val::global("contentPanelManager") * .setFileTrackingAttention(panelId, {conflicts, failed, pulse, conflictsText, failedText}): the counts as - * numbers, pulse true when the glow should play now, and the translated badge titles ("5 conflicts wait", - * "6 uploads failed", empty at 0). + * numbers, pulse true when the glow should play now, and the translated badge titles ("5 conflicts wait", "6 + * uploads failed", empty at 0). */ class DockTabAttention { diff --git a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp index a982eb3a..2327470a 100644 --- a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp +++ b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp @@ -12,11 +12,11 @@ namespace FileTracking { /** * @brief The Failed tab: an itemListBox counting item->failureCount, with Retry All (data-ft-action=retry-all, - * Primary) and Dismiss All (dismiss-all), the hint notRetriedAutomatically and the empty text noFailures. One row - * per item->failures (data-ft-failure-id, data-ft-reason=): the file, the reason (its title - * the detail when there is one, plus failedTimes after more than one attempt), the time, Retry (retry, - * actions.retryFailure) and a dismiss icon button (dismiss, title dismiss, aria-label dismissFailureLabel, - * actions.dismissFailure). + * Primary) and Dismiss All (dismiss-all), the hint notRetriedAutomatically and the empty text noFailures. + * One row per item->failures (data-ft-failure-id, data-ft-reason=): the file, the + * reason (its title the detail when there is one, plus failedTimes after more than one attempt), the time, + * Retry (retry, actions.retryFailure) and a dismiss icon button (dismiss, title dismiss, aria-label + * dismissFailureLabel, actions.dismissFailure). */ Nui::ElementRenderer failuresTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp index 29fa6e5b..92e4fd56 100644 --- a/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp +++ b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp @@ -130,8 +130,9 @@ namespace FileTracking std::string stopSideText(FileTrackingTexts const& texts, SharedData::WatchStopReason reason); /** - * @brief The sentence of the stop reason strip for @p stop. Overlap uses @ref - * FileTrackingTexts::StopReasons::overlap when movedTo and otherWatchName are set, overlapUnknown otherwise. + * @brief The sentence of the stop reason strip for @p stop. Overlap uses + * @ref FileTrackingTexts::StopReasons::overlap when movedTo and otherWatchName are set, overlapUnknown + * otherwise. */ std::string stopReasonText(FileTrackingTexts const& texts, SharedData::WatchStop const& stop); @@ -172,7 +173,7 @@ namespace FileTracking /** * @brief How a file of a watch is shown in its lists: its path below @p watched with '/' separators, or its file - * name when @p path is @p watched itself or not below it. + * name when @p path is @p watched itself or not below it. */ std::string pathWithin(Paths::LocalPath const& watched, Paths::LocalPath const& path); } diff --git a/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp b/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp index 18a5245b..906941e3 100644 --- a/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp +++ b/frontend/include/frontend/session_components/file_tracking/orphan_detail.hpp @@ -13,8 +13,8 @@ namespace FileTracking /** * @brief The detail pane of an orphaned copy (data-ft-detail=orphan, data-ft-instance-id): its glyph, name and kind * label orphanKind, the actions Show Folder (data-ft-action=show-folder, actions.openLocally on its - * directory) and Delete Copy (delete-orphan, Danger, actions.deleteOrphanedCopy, which confirms first), the lines - * orphanDownloadedFrom or orphanOriginUnknown, orphanDownloadedAt, orphanEndedAt and orphanSize, and + * directory) and Delete Copy (delete-orphan, Danger, actions.deleteOrphanedCopy, which confirms first), the + * lines orphanDownloadedFrom or orphanOriginUnknown, orphanDownloadedAt, orphanEndedAt and orphanSize, and * orphanExplanation. */ Nui::ElementRenderer diff --git a/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp b/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp index f13ff4a3..3ca8b489 100644 --- a/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp +++ b/frontend/include/frontend/session_components/file_tracking/orphan_row.hpp @@ -12,9 +12,9 @@ namespace FileTracking { /** * @brief The outliner row of an orphaned copy: a listRow (data-ft-instance-id, data-ft-name) led by orphanGlyph, - * with the size item->sideText beside it and a trash button (data-ft-action=delete-orphan, title + * with the size item->sideText beside it and a trash button (data-ft-action=delete-orphan, title * deleteOrphanTooltip) calling actions.deleteOrphanedCopy. Selected while store.selection() holds its - * instance; activating it calls actions.select. + * instance; activating it calls actions.select. */ Nui::ElementRenderer orphanRow(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/outliner.hpp b/frontend/include/frontend/session_components/file_tracking/outliner.hpp index 7c5d3f88..c5f20b8a 100644 --- a/frontend/include/frontend/session_components/file_tracking/outliner.hpp +++ b/frontend/include/frontend/session_components/file_tracking/outliner.hpp @@ -14,8 +14,8 @@ namespace FileTracking * store.orphanMembers() with orphanRow. Its headerEnd holds countBadge(store.countOf(group)); Stopped adds a * trash button (data-ft-action=remove-all-stopped, actions.removeAllStopped) and Orphaned one * (data-ft-action=delete-all-orphans, actions.deleteAllOrphanedCopies). A group without members stays - * mounted and gets data-hidden. Up, Down, Home and End on the list call actions.moveSelection; folding a group - * calls actions.setGroupExpanded; typing calls actions.filter. + * mounted and gets data-hidden. Up, Down, Home and End on the list call actions.moveSelection; folding a + * group calls actions.setGroupExpanded; typing calls actions.filter. */ Nui::ElementRenderer outliner(TrackingStore& store, TrackingActions const& actions); } diff --git a/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp index 0f1f4ef2..530de047 100644 --- a/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp +++ b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp @@ -12,7 +12,7 @@ namespace FileTracking { /** * @brief Why a watch stopped: a messageStrip (data-ft-reason=) with item->stopText, mounted always - * and hidden while the watch runs. Its actions slot holds Show “Y” (data-ft-action=show-other-watch, text + * and hidden while the watch runs. Its actions slot holds Show “Y” (data-ft-action=show-other-watch, text * showOtherWatch, actions.showWatch) for an Overlap stop that names the other watch, and Open Again * (open-again, actions.openAgain) for any other stopped copy. Stopped originals get no action. */ diff --git a/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp index b7207555..2c61a629 100644 --- a/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp +++ b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp @@ -12,11 +12,11 @@ namespace FileTracking { /** * @brief The pinned progress of a watch (aria-label progressLabel). While Syncing: progressFiles and - * progressWaiting (plus progressWaitingForAnswer while answers are due), a total progressBar in Bytes mode on - * item->bytesDone and item->bytesTotal (data-ft-progress-total), and per row of item->inFlight its name and a small - * progressBar (data-ft-progress-file=). Otherwise a summary line (statusUpToDate, - * progressUpToDateLastUpload, progressStoppedLastUpload, progressConflictsWait(One) or progressFilesUpToDate) and, - * after a sync, a bar of the last sync. + * progressWaiting (plus progressWaitingForAnswer while answers are due), a total progressBar in Bytes mode + * on item->bytesDone and item->bytesTotal (data-ft-progress-total), and per row of item->inFlight its name + * and a small progressBar (data-ft-progress-file=). Otherwise a summary line (statusUpToDate, + * progressUpToDateLastUpload, progressStoppedLastUpload, progressConflictsWait(One) or + * progressFilesUpToDate) and, after a sync, a bar of the last sync. */ Nui::ElementRenderer syncProgressBlock(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp b/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp index 90bb244c..ffe6323e 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp @@ -33,9 +33,9 @@ namespace FileTracking ROAR_PIMPL_SPECIAL_FUNCTIONS_NO_MOVE(TrackerClient); /** - * @brief Follows the tracker of @p sessionId from now on: deactivates first, registers onEntry, - * onEntryRemoved, onEntriesEnded, onConflict, onConflictRemoved, onFailure, onFailureRemoved, - * onWatchProgress and onOrphanedCopies, then asks for the orphan list. + * @brief Follows the tracker of @p sessionId from now on: deactivates first, registers onEntry, onEntryRemoved, + * onEntriesEnded, onConflict, onConflictRemoved, onFailure, onFailureRemoved, onWatchProgress and + * onOrphanedCopies, then asks for the orphan list. */ void activate(Ids::SessionId sessionId); diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp index a14b7832..13685f6e 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp @@ -26,8 +26,8 @@ namespace FileTracking /** * @brief The state of the File Tracking panel of one session: the TrackingModel and its projection onto the * observed items the views read, plus the selection, the detail tab, the filter and the folded groups. - * TrackerClient feeds it, the views read it, TrackingActions change it. No setter syncs; the entry point - * (an event handler, an RPC reply) syncs once. + * TrackerClient feeds it, the views read it, TrackingActions change it. No setter syncs; the entry point (an + * event handler, an RPC reply) syncs once. */ class TrackingStore { @@ -82,8 +82,8 @@ namespace FileTracking void clear(); /** - * @return What the tracker sent, for lookups by the facade (the remote path for Open Again, a copy's name for - * a confirmation). + * @return What the tracker sent, for lookups by the facade (the remote path for Open Again, a copy's name for a + * confirmation). */ TrackingModel const& model() const; diff --git a/frontend/include/frontend/session_components/file_tracking/watch_header.hpp b/frontend/include/frontend/session_components/file_tracking/watch_header.hpp index c22dc28e..e6d326ff 100644 --- a/frontend/include/frontend/session_components/file_tracking/watch_header.hpp +++ b/frontend/include/frontend/session_components/file_tracking/watch_header.hpp @@ -12,11 +12,12 @@ namespace FileTracking { /** * @brief The head of the watch detail: the kind icon, the name, the kind label - * (fileExplorer.contextMenu.openAndWatch for copies, uploadAndWatch for originals), the status badge - * (data-ft-status=, text item->statusText) and the actions. A running watch offers Open Local Item - * (data-ft-action=open-local, actions.openLocally), Reveal on Server (reveal-on-server, actions.revealOnServer) and - * Stop (stop, actions.stop); a stopped one Open Local Item and Remove (remove, actions.removeStopped). Below, a - * muted line "local path -> remote path", the local part temporaryCopy for copies. + * (fileExplorer.contextMenu.openAndWatch for copies, uploadAndWatch for originals), the status badge + * (data-ft-status=, text item->statusText) and the actions. A running watch offers Open Local + * Item (data-ft-action=open-local, actions.openLocally), Reveal on Server (reveal-on-server, + * actions.revealOnServer) and Stop (stop, actions.stop); a stopped one Open Local Item and Remove (remove, + * actions.removeStopped). Below, a muted line "local path -> remote path", the local part temporaryCopy for + * copies. */ Nui::ElementRenderer watchHeader(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/watch_row.hpp b/frontend/include/frontend/session_components/file_tracking/watch_row.hpp index 9e5af0ea..4e6f50a5 100644 --- a/frontend/include/frontend/session_components/file_tracking/watch_row.hpp +++ b/frontend/include/frontend/session_components/file_tracking/watch_row.hpp @@ -12,7 +12,7 @@ namespace FileTracking { /** * @brief The outliner row of a watch: a listRow (data-ft-watch-id, data-ft-name, data-ft-state=) led - * by a progressRing on item->ringFraction while the watch is Syncing (label syncingTooltip), by statusGlyph + * by a progressRing on item->ringFraction while the watch is Syncing (label syncingTooltip), by statusGlyph * otherwise. Its side text is item->sideText; it is selected while store.selection() holds its watch and * activating it calls actions.select. A stopped row gets a trash button (data-ft-action=remove-stopped, * title removeStoppedTooltip) calling actions.removeStopped. From 8c89e1bed6615155179d62f63a25229384e8e2f7 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:20:21 +0200 Subject: [PATCH 034/114] Moved the operation queue cards to the library progress bar - Cards show "0%" until their first progress or their end, as the old bar did - The transfer card syncs after its progress, the old bar's setProgress did that - Queue styles target the library's bar parts and text variables --- .../session_components/operation_queue.hpp | 1 - .../displayed_bulk_operation.hpp | 33 ++++++++++--------- .../displayed_delete_operation.hpp | 17 ++++++---- .../displayed_transfer_operation.hpp | 20 +++++++---- static/styles/operation_queue.css | 14 ++++---- 5 files changed, 49 insertions(+), 36 deletions(-) diff --git a/frontend/include/frontend/session_components/operation_queue.hpp b/frontend/include/frontend/session_components/operation_queue.hpp index c9c8db2b..332fb883 100644 --- a/frontend/include/frontend/session_components/operation_queue.hpp +++ b/frontend/include/frontend/session_components/operation_queue.hpp @@ -23,7 +23,6 @@ #include #include -#include #include #include #include diff --git a/frontend/include/frontend/session_components/operation_queue/displayed_bulk_operation.hpp b/frontend/include/frontend/session_components/operation_queue/displayed_bulk_operation.hpp index 16d386a6..7c5916a0 100644 --- a/frontend/include/frontend/session_components/operation_queue/displayed_bulk_operation.hpp +++ b/frontend/include/frontend/session_components/operation_queue/displayed_bulk_operation.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -42,10 +43,9 @@ struct DisplayedBulkOperation , remotePath_{std::move(remotePath)} , totalProgressBar_({ .height = std::string{progressHeight}, - .min = 0, - .max = 1, - .showMinMax = true, - .byteMode = true, + .minimum = 0, + .maximum = 1, + .texts = {.percent = "{:.0f}%"}, }) {} @@ -86,7 +86,7 @@ struct DisplayedBulkOperation row = claimFreeRow(file.entryIndex); if (row->name.value() != file.file) row->name = file.file; - row->bar.max(static_cast(file.totalBytes)); + row->bar.setMaximum(static_cast(file.totalBytes)); row->bar.setProgress(static_cast(file.bytes)); } trimFreeTailRows(); @@ -95,15 +95,15 @@ struct DisplayedBulkOperation // forever; fall back to counting entries so the bar still moves. if (progress.bytesTotal > 0) { - totalProgressBar_.byteMode(true); + totalProgressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Bytes); totalProgressBar_.setProgress(static_cast(progress.bytesCurrent)); - totalProgressBar_.max(static_cast(progress.bytesTotal)); + totalProgressBar_.setMaximum(static_cast(progress.bytesTotal)); } else { - totalProgressBar_.byteMode(false); + totalProgressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Counts); totalProgressBar_.setProgress(static_cast(progress.fileCurrentIndex)); - totalProgressBar_.max(static_cast(progress.fileCount)); + totalProgressBar_.setMaximum(static_cast(progress.fileCount)); } bytesPerSecond = progress.bytesPerSecond; @@ -212,8 +212,10 @@ struct DisplayedBulkOperation newState == SharedData::OperationState::PartialSuccess) { bytesPerSecond = 0; - totalProgressBar_.setProgress(totalProgressBar_.max()); + totalProgressBar_.setProgress(totalProgressBar_.maximum()); } + if (totalProgressBar_.textMode() == ScriptNuiComponents::ProgressBarTextMode::Percent) + totalProgressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Bytes); totalProgressBar_.setZeroAsComplete(); resizeInFlightRows(0); } @@ -231,11 +233,11 @@ struct DisplayedBulkOperation { std::optional entryIndex{}; Nui::Observed name{""}; - Components::ProgressBar bar{{ + ScriptNuiComponents::ProgressBar bar{{ .height = std::string{progressHeight}, - .min = 0, - .max = 1, - .showMinMax = true, + .minimum = 0, + .maximum = 1, + .showMinimumAndMaximum = true, .byteMode = true, }}; @@ -342,7 +344,8 @@ struct DisplayedBulkOperation Nui::Observed fileCount{0ull}; Nui::Observed> bytesPerSecond{0}; - Components::ProgressBar totalProgressBar_; + /** @brief Shows "0%" until the first progress or the end, then bytes, or counts without a byte total. */ + ScriptNuiComponents::ProgressBar totalProgressBar_; // Mutable because Nui::range only binds to a non-const Observed and body() is const. mutable Nui::Observed>> inFlightRows_{}; }; diff --git a/frontend/include/frontend/session_components/operation_queue/displayed_delete_operation.hpp b/frontend/include/frontend/session_components/operation_queue/displayed_delete_operation.hpp index b2d67175..6af702a6 100644 --- a/frontend/include/frontend/session_components/operation_queue/displayed_delete_operation.hpp +++ b/frontend/include/frontend/session_components/operation_queue/displayed_delete_operation.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -36,10 +37,9 @@ struct DisplayedDeleteOperation , recursive_{scanning} , progressBar_({ .height = std::string{progressHeight}, - .min = 0, - .max = 0, - .showMinMax = true, - .byteMode = false, + .minimum = 0, + .maximum = 0, + .texts = {.percent = "{:.0f}%"}, }) {} @@ -52,7 +52,10 @@ struct DisplayedDeleteOperation { OperationCard::state(newState); if (isCompletedState()) + { + progressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Counts); progressBar_.setZeroAsComplete(); + } } void setProgress(SharedData::BulkDeleteProgress const& progress) @@ -66,8 +69,9 @@ struct DisplayedDeleteOperation if (totalFiles_.value() != progress.totalFiles) totalFiles_ = progress.totalFiles; + progressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Counts); progressBar_.setProgress(progress.filesDeleted); - progressBar_.max(static_cast(progress.totalFiles)); + progressBar_.setMaximum(static_cast(progress.totalFiles)); Nui::globalEventContext.executeActiveEventsImmediately(); } @@ -141,5 +145,6 @@ struct DisplayedDeleteOperation Paths::RemotePath target_; /** @brief The delete scanned a directory, so it removes content and resumes doing the same. */ bool recursive_{false}; - Components::ProgressBar progressBar_; + /** @brief Shows "0%" until the first progress or the end, then counts. */ + ScriptNuiComponents::ProgressBar progressBar_; }; \ No newline at end of file diff --git a/frontend/include/frontend/session_components/operation_queue/displayed_transfer_operation.hpp b/frontend/include/frontend/session_components/operation_queue/displayed_transfer_operation.hpp index 4fc8f4c6..4e82ff27 100644 --- a/frontend/include/frontend/session_components/operation_queue/displayed_transfer_operation.hpp +++ b/frontend/include/frontend/session_components/operation_queue/displayed_transfer_operation.hpp @@ -5,6 +5,7 @@ #include #include +#include #include class DisplayedTransferOperation @@ -35,10 +36,9 @@ class DisplayedTransferOperation , ScanningCard{scanning} , progressBar_{{ .height = std::string{progressHeight}, - .min = 0, - .max = max, - .showMinMax = true, - .byteMode = true, + .minimum = 0, + .maximum = max, + .texts = {.percent = "{:.0f}%"}, }} , localPath_{std::move(localPath)} , remotePath_{std::move(remotePath)} @@ -110,21 +110,26 @@ class DisplayedTransferOperation void setProgress(SharedData::TransferProgress progress) { scanPhase_.end(); - progressBar_.max(progress.max); + progressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Bytes); + progressBar_.setMaximum(progress.max); progressBar_.setProgress(progress.current - progress.min); bytesPerSecond_ = progress.bytesPerSecond; + Nui::globalEventContext.executeActiveEventsImmediately(); } bool warrantsCancelConfirm() const override { - return !isCompletedState() && progressBar_.max() > 10'000'000; // 10 MB + return !isCompletedState() && progressBar_.maximum() > 10'000'000; // 10 MB } void state(SharedData::OperationState newState) override { OperationCard::state(newState); if (isCompletedState()) + { + progressBar_.setTextMode(ScriptNuiComponents::ProgressBarTextMode::Bytes); progressBar_.setZeroAsComplete(); + } } /** @@ -150,7 +155,8 @@ class DisplayedTransferOperation } private: - Components::ProgressBar progressBar_; + /** @brief Shows "0%" until the first progress or the end, then bytes. */ + ScriptNuiComponents::ProgressBar progressBar_; Nui::Observed> bytesPerSecond_{0}; /** @brief Empty for an archive upload. */ Paths::LocalPath localPath_; diff --git a/static/styles/operation_queue.css b/static/styles/operation_queue.css index b499a0ef..460d62cb 100644 --- a/static/styles/operation_queue.css +++ b/static/styles/operation_queue.css @@ -194,13 +194,13 @@ header.opq-controls { flex-shrink: 0; } - .progress-bar { + .script-nui-progress-bar { + --script-nui-components-progress-bar-text-color: rgba(255, 255, 255, 0.9); + --script-nui-components-progress-bar-text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); font-size: 10px; } - .progress-bar>div:nth-child(2) { - color: rgba(255, 255, 255, 0.9); - text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + .script-nui-progress-bar-text { -webkit-font-smoothing: antialiased; transform: translateZ(0); } @@ -456,7 +456,7 @@ header.opq-controls { } /* total bar spans the full card width */ - &>.opq-bulk-total>.progress-bar { + &>.opq-bulk-total>.script-nui-progress-bar { width: 100%; border-radius: 8px; font-family: Inconsolata, Hack, JetBrains Mono, Terminus, Fixed, monospace; @@ -480,7 +480,7 @@ header.opq-controls { min-width: 0; font-size: 0.85em; - &>.progress-bar { + &>.script-nui-progress-bar { width: 100%; border-radius: 6px; font-family: Inconsolata, Hack, JetBrains Mono, Terminus, Fixed, monospace; @@ -491,7 +491,7 @@ header.opq-controls { .opq-progress-grow { flex-grow: 1; - &>.progress-bar { + &>.script-nui-progress-bar { width: 100%; border-radius: 0; font-family: Inconsolata, Hack, JetBrains Mono, Terminus, Fixed, monospace; From a3a41ec184b4e656fab5f207b008ab261d00c7b2 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:20:25 +0200 Subject: [PATCH 035/114] Moved the sync progress dialog's hash bar to the library progress bar --- .../frontend/sync_dialog/sync_progress_dialog.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/source/frontend/sync_dialog/sync_progress_dialog.cpp b/frontend/source/frontend/sync_dialog/sync_progress_dialog.cpp index e1d4c08a..64e18150 100644 --- a/frontend/source/frontend/sync_dialog/sync_progress_dialog.cpp +++ b/frontend/source/frontend/sync_dialog/sync_progress_dialog.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include @@ -23,6 +22,7 @@ #include #include +#include #include #include @@ -49,11 +49,11 @@ struct SyncProgressDialog::Implementation std::string failureReason_{}; // Hashing progress bar — total becomes known when comparing finishes - Components::ProgressBar hashProgressBar_{Components::ProgressBar::Settings{ + ScriptNuiComponents::ProgressBar hashProgressBar_{ScriptNuiComponents::ProgressBar::Settings{ .height = "10px", - .min = 0, - .max = 0, - .showMinMax = true, + .minimum = 0, + .maximum = 0, + .showMinimumAndMaximum = true, }}; std::filesystem::path localPath_{}; @@ -107,7 +107,7 @@ void SyncProgressDialog::open( impl_->localListed_ = 0ull; impl_->remoteListed_ = 0ull; impl_->compared_ = 0ull; - impl_->hashProgressBar_.max(0); + impl_->hashProgressBar_.setMaximum(0); impl_->open_ = true; Nui::globalEventContext.executeActiveEventsImmediately(); From 57dd1055a78699533f539ea8ed5975e273d08d84 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:21:05 +0200 Subject: [PATCH 036/114] Removed the host progress bar, the library's replaces it --- .../frontend/components/progress_bar.hpp | 68 ----- frontend/source/frontend/CMakeLists.txt | 1 - .../frontend/components/progress_bar.cpp | 237 ------------------ 3 files changed, 306 deletions(-) delete mode 100644 frontend/include/frontend/components/progress_bar.hpp delete mode 100644 frontend/source/frontend/components/progress_bar.cpp diff --git a/frontend/include/frontend/components/progress_bar.hpp b/frontend/include/frontend/components/progress_bar.hpp deleted file mode 100644 index dc742b3d..00000000 --- a/frontend/include/frontend/components/progress_bar.hpp +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace Components -{ - class ProgressBar - { - public: - struct Settings - { - std::string height{"30px"}; - long long min{0}; - long long max{100}; - bool showMinMax{false}; - bool byteMode{false}; - }; - ProgressBar(Settings settings); - - ROAR_PIMPL_SPECIAL_FUNCTIONS(ProgressBar); - - Nui::ElementRenderer operator()(std::string const& extraStyleOptions = "") const; - - /** - * Set the progress of the progress bar. - * Cannot be set if the progress bar is not mounted. - */ - void setProgress(long long current); - - /** - * @brief Set the maximum value of the progress bar. - * - * @param max The maximum value. - */ - void max(long long max); - - /** - * @brief Get the maximum value of the progress bar. - * - * @return long long - */ - long long max() const; - - /** - * @brief Sometimes a progress bar from 0 to 0 makes sense. Initially shown as 0% but once marked as complete, - * it should show 100%. Call this to enable that behavior. - */ - void setZeroAsComplete(); - - /** - * @brief Switch between byte formatting and plain counts for the min/max text. - * - * @param enabled True formats values as bytes, false shows them as plain numbers. - */ - void byteMode(bool enabled); - - private: - void updateText(); - void recalculate(); - - private: - struct Implementation; - std::unique_ptr impl_; - }; -} \ No newline at end of file diff --git a/frontend/source/frontend/CMakeLists.txt b/frontend/source/frontend/CMakeLists.txt index cf10f9fc..4e9f36e0 100644 --- a/frontend/source/frontend/CMakeLists.txt +++ b/frontend/source/frontend/CMakeLists.txt @@ -177,7 +177,6 @@ target_sources( terminal/file_engine.cpp terminal/ssh_engine.cpp terminal/ssh_channel.cpp - components/progress_bar.cpp components/code_editor.cpp onboarding/onboarding.cpp file_explorer/side_model.cpp diff --git a/frontend/source/frontend/components/progress_bar.cpp b/frontend/source/frontend/components/progress_bar.cpp deleted file mode 100644 index d1eaf161..00000000 --- a/frontend/source/frontend/components/progress_bar.cpp +++ /dev/null @@ -1,237 +0,0 @@ -#include -#include - -#include -#include -#include -#include - -#include - -// clang-format off -#ifdef NUI_INLINE -// @inline(css, scp-progress-bar) -.progress-bar { - height: 30px; - background-color: #606060; - border-radius: 15px; - overflow: hidden; - position: relative; - display: flex; - justify-content: center; - align-items: center; - user-select: none; -} - -.progress-bar > div:nth-child(1) { - height: 100%; - width: 0%; - /*transition: width 0.3s linear, background-color 0.3s linear;*/ - background-color: #4CAF50; - position: absolute; - left: 0; -} - -.progress-bar > div:nth-child(2) { - position: relative; - z-index: 1; - font-weight: bold; - color: #303030; - text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); - text-align: center; -} - -.progress-bar > div:nth-child(3) { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.5), transparent); - animation: progress-shine 2s infinite; -} - -.progress-bar > div:nth-child(3).hidden { - display: none; -} - -@media (prefers-reduced-motion: reduce) { - .progress-bar > div:nth-child(3) { - animation: none; - } -} - -@keyframes progress-shine { - 0% { - transform: translateX(-100%); - } - - 100% { - transform: translateX(100%); - } -} -// @endinline -#endif -// clang-format on - -namespace -{ - double percentageBetween(long long current, long long min, long long max, bool zeroIsComplete) - { - // Ensure min is less than or equal to max - if (min > max) - std::swap(min, max); - - if (max == 0) - return zeroIsComplete ? 100. : 0.; - - // Clamp current between min and max - current = std::clamp(current, min, max); - - // Avoid division by zero if min == max - if (min == max) - return 100.; - - // Calculate percentage - return 100. * (static_cast(current - min) / static_cast(max - min)); - } -} - -namespace Components -{ - struct ProgressBar::Implementation : public ProgressBar::Settings - { - Nui::Observed progress{0}; - Nui::Observed maxObserved; - Nui::Observed text{"0%"}; - Nui::Observed backgroundColor{"#a0a0a0"}; - Nui::Observed zeroAsComplete{false}; - Utility::OrderOfMagnitude magnitude; - - Implementation(Settings settings) - : Settings{std::move(settings)} - , maxObserved{max} - , magnitude{Utility::determineOrderOfMagnitude(max)} - {} - }; - - ProgressBar::ProgressBar(Settings settings) - : impl_{std::make_unique(std::move(settings))} - {} - - ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL(ProgressBar); - - void ProgressBar::setZeroAsComplete() - { - impl_->zeroAsComplete = true; - recalculate(); - } - - void ProgressBar::byteMode(bool enabled) - { - if (impl_->byteMode == enabled) - return; - impl_->byteMode = enabled; - recalculate(); - } - - Nui::ElementRenderer ProgressBar::operator()(std::string const& extraStyleOptions) const - { - using namespace Nui::Elements; - using namespace Nui::Attributes; - using Nui::Elements::div; - using fmt::format; - - // clang-format off - return div{ - class_ = "progress-bar", - style = fmt::format("height: {}; {}", impl_->height, extraStyleOptions) - }( - // bar fill - div{ - style = observe(impl_->progress, impl_->maxObserved, impl_->backgroundColor, impl_->zeroAsComplete).generate([this]() { - return format( - "width: {:.2f}%; height: {}; background-color: {};", - percentageBetween(impl_->progress.value(), impl_->min, impl_->maxObserved.value(), impl_->zeroAsComplete.value()), - impl_->height, - impl_->backgroundColor.value() - ); - }) - }(), - // text - div{}(impl_->text), - // shine animation - div{ - class_ = observe(impl_->progress, impl_->maxObserved).generate([this]() -> std::string { - return impl_->progress.value() >= impl_->maxObserved.value() ? "hidden" : ""; - }) - }() - ); - // clang-format on - } - - void ProgressBar::updateText() - { - impl_->text = - [this, - percent = percentageBetween( - impl_->progress.value(), impl_->min, impl_->maxObserved.value(), impl_->zeroAsComplete.value() - )]() - { - if (impl_->showMinMax) - { - if (impl_->byteMode) - { - return fmt::format( - "{} - {} ({:.1f}%)", - Utility::formatBytes(impl_->progress.value(), impl_->magnitude), - Utility::formatBytes(impl_->maxObserved.value(), impl_->magnitude), - percent - ); - } - else - return fmt::format( - "{} / {} ({:.1f}%)", impl_->progress.value(), impl_->maxObserved.value(), percent - ); - } - else - return fmt::format("{:.1f}%", percent); - }(); - } - - void ProgressBar::recalculate() - { - const auto current = impl_->progress.value(); - const auto percent = - percentageBetween(current, impl_->min, impl_->maxObserved.value(), impl_->zeroAsComplete.value()); - const auto hue = current == impl_->maxObserved.value() - ? 120. - : std::max(5., 120. * std::pow(static_cast(percent) / 100., 1.8)); - - impl_->progress = current; - impl_->backgroundColor = fmt::format("hsl({}, 40%, 45%)", static_cast(hue)); - updateText(); - } - - void ProgressBar::setProgress(long long current) - { - impl_->progress = current; - recalculate(); - Nui::globalEventContext.executeActiveEventsImmediately(); - } - - long long ProgressBar::max() const - { - return impl_->maxObserved.value(); - } - - void ProgressBar::max(long long max) - { - if (max != impl_->maxObserved.value()) - { - impl_->magnitude = Utility::determineOrderOfMagnitude(max); - impl_->maxObserved = max; - recalculate(); - } - } -} \ No newline at end of file From 3e9f0deecdb92011be2bccb0f8d4c5838485f183 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:28:30 +0200 Subject: [PATCH 037/114] Listed server changes as conflicts per watch instead of asking one at a time - Conflicts are answered one by one or all of a watch, Replace checks the path again, Keep asks on the next save - forget, stopAll and connectionLost drop a watch's conflicts and leave the server order, so a stopped watch no longer keeps an open question - The question tests now answer conflicts and check their kind and times; the fake view fails on ask() and the fake remote tells what the server has when an upload finds it changed --- .../source/backend/file_tracking/tracker.cpp | 309 ++++++++----- backend/test/backend/test_tracker.hpp | 405 +++++++++++++++--- 2 files changed, 535 insertions(+), 179 deletions(-) diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index f9cc58e2..16ad0deb 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -15,7 +16,6 @@ #include #include -#include #include #include #include @@ -199,16 +199,70 @@ namespace FileTracking return status && *status && std::filesystem::is_directory(**status); } + std::uint64_t secondsSinceEpochOf(std::filesystem::file_time_type time) + { + const auto seconds = std::chrono::duration_cast( + std::chrono::file_clock::to_sys(time).time_since_epoch() + ); + return static_cast(seconds.count()); + } + + std::uint64_t secondsSinceEpochNow() + { + const auto seconds = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); + return static_cast(seconds.count()); + } + /** * @brief What the server has of a file an upload sent in @p sent: uploads carry the modification time along, * in seconds. */ ServerState serverStateOf(SourceState const& sent) { - const auto seconds = std::chrono::duration_cast( - std::chrono::file_clock::to_sys(sent.modified).time_since_epoch() - ); - return ServerState{.mtime = static_cast(seconds.count()), .size = sent.size}; + return ServerState{.mtime = secondsSinceEpochOf(sent.modified), .size = sent.size}; + } + + /** + * @brief What the server has at the destination of an upload instead of what the tracker left there. + */ + struct ServerChange + { + SharedData::ConflictKind kind{SharedData::ConflictKind::ChangedOnServer}; + /** @brief What it has there now, nothing when it has nothing there or could not be asked. */ + std::optional current{}; + }; + + /** + * @brief How the server changed where the tracker knew of something there (@p knownThere) or of nothing, and + * it has @p current now. Changed when that cannot be told. + */ + ServerChange serverChangeOf(bool knownThere, std::optional const& current) + { + if (!knownThere && current) + return {.kind = SharedData::ConflictKind::CreatedOnServer, .current = current}; + if (knownThere && !current) + return {.kind = SharedData::ConflictKind::DeletedOnServer, .current = std::nullopt}; + return {.kind = SharedData::ConflictKind::ChangedOnServer, .current = current}; + } + + /** + * @brief The conflict @p upload raised, the server changed its destination as @p change tells. Its id is set + * when it is listed. + */ + SharedData::WatchConflict conflictOf(Upload const& upload, ServerChange const& change) + { + const auto localState = sourceStateOf(upload.change.path.native()); + return { + .conflictId = {}, + .watchId = upload.watchId, + .kind = change.kind, + .localPath = upload.change.path, + .remotePath = upload.destination, + .serverModifiedAt = change.current ? std::optional{change.current->mtime} : std::nullopt, + .localModifiedAt = localState ? std::optional{secondsSinceEpochOf(localState->modified)} : std::nullopt, + .raisedAt = secondsSinceEpochNow(), + }; } /** @@ -369,15 +423,6 @@ namespace FileTracking std::uint64_t claimId_; }; - /** - * @brief A question the view asks, see TrackerView::ask(). - */ - struct OpenQuestion - { - Ids::QuestionId questionId; - Upload upload; - }; - using OnOpened = std::function const&)>; boost::asio::strand strand; @@ -396,9 +441,8 @@ namespace FileTracking /** @brief The remote paths of watches that are starting, by claim. */ std::map claims{}; std::uint64_t nextClaimId{0}; - /** @brief Questions waiting for the one the view asks, so no question replaces another. */ - std::deque waitingQuestions{}; - std::optional openQuestion{}; + /** @brief The conflicts waiting for the user's answer, each with the upload that waits for it. */ + ConflictList conflicts{}; /** @brief Changes that ended since nothing was on its way any more, see showProgress(). */ std::uint64_t changesDone{0}; SharedData::TrackerProgress shownProgress{}; @@ -523,7 +567,7 @@ namespace FileTracking return { .pending = pending, .done = changesDone, - .waitingForAnswer = waitingQuestions.size() + (openQuestion ? 1u : 0u), + .waitingForAnswer = conflicts.size(), }; } @@ -532,7 +576,7 @@ namespace FileTracking */ bool nothingOnItsWay() const { - return !openQuestion && waitingQuestions.empty() && + return conflicts.empty() && std::ranges::all_of( std::views::values(entries), [](Entry const& entry) @@ -906,21 +950,13 @@ namespace FileTracking if (node.empty()) return std::nullopt; Log::info("FileTracking: stopping watch {}", watchId.value()); - std::vector dropped; - std::erase_if( - waitingQuestions, - [&watchId, &dropped](Upload const& upload) - { - if (upload.watchId != watchId) - return false; - dropped.push_back(upload); - return true; - } - ); + const auto dropped = conflicts.removeWatch(watchId); + for (auto const& listed : dropped) + view->removeConflict(listed.conflict.conflictId); view->removeEntry(watchId); progressChanged(); - for (auto const& upload : dropped) - leaveOrder(upload); + for (auto const& listed : dropped) + leaveOrder(listed.context); const auto* copy = node.mapped().downloadedCopy(); return copy ? std::optional{copy->instanceId} : std::nullopt; } @@ -973,13 +1009,10 @@ namespace FileTracking for (auto const& instanceId : copies) releaseInstance(instanceId); claims.clear(); - auto dropped = std::exchange(waitingQuestions, {}); - if (openQuestion) - dropped.push_back(std::move(openQuestion->upload)); - openQuestion.reset(); + const auto dropped = conflicts.removeAll(); const auto waiting = std::exchange(waitingOperations, {}); - for (auto const& upload : dropped) - leaveOrder(upload); + for (auto const& listed : dropped) + leaveOrder(listed.context); for (auto const& ticket : std::views::keys(waiting)) leaveOrder(ticket); progressTimer.cancel(); @@ -992,12 +1025,19 @@ namespace FileTracking void connectionLost() { auto ended = SharedData::EndedWatches{.reason = SharedData::WatchEndReason::ConnectionLost, .entries = {}}; + std::vector droppedConflicts; for (auto const& entry : std::views::values(entries)) + { ended.entries.push_back(shownEntryOf(entry)); + for (auto const& conflict : conflicts.conflictsOf(entry.watchId)) + droppedConflicts.push_back(conflict.conflictId); + } Log::info("FileTracking: the connection was lost, ending {} watch(es)", ended.entries.size()); // stopAll() forgets the progress shown, this view still shows it. const bool progressShown = shownProgress != SharedData::TrackerProgress{}; stopAll(); + for (auto const& conflictId : droppedConflicts) + view->removeConflict(conflictId); for (auto const& shown : ended.entries) view->removeEntry(shown.watchId); if (!ended.entries.empty()) @@ -1802,16 +1842,16 @@ namespace FileTracking auto expected = ExpectedDestination{.file = entry->serverContent.fileState(upload.change.path)}; return enqueueUpload(std::move(upload), std::move(expected)); } - if (serverChangedMeanwhile(*entry, upload)) - return askServerChanged(std::move(upload)); + if (const auto change = serverChangeAt(*entry, upload)) + return askServerChanged(std::move(upload), *change); enqueueUpload(std::move(upload)); } /** - * @brief Whether the server's copy at the destination of @p upload, a directory or a symbolic link, is not - * what this tracker left there. A symbolic link is compared as itself. + * @brief How the server's copy at the destination of @p upload, a directory or a symbolic link, is not what + * this tracker left there, nothing when it is. A symbolic link is compared as itself. */ - bool serverChangedMeanwhile(Entry const& entry, Upload const& upload) + std::optional serverChangeAt(Entry const& entry, Upload const& upload) { const auto current = isSymlink(upload.change.path) ? remote->linkStat(upload.destination) : remote->stat(upload.destination); @@ -1821,11 +1861,18 @@ namespace FileTracking "FileTracking: cannot check '{}' on the server: {}", upload.destination.value(), current.error() ); // An unanswered check counts as changed. - return true; + return ServerChange{}; } if (upload.change.isDirectory) - return current->has_value() && !entry.serverContent.has(upload.change.path); - return serverChanged(entry.serverContent.fileState(upload.change.path), *current); + { + if (!current->has_value() || entry.serverContent.has(upload.change.path)) + return std::nullopt; + return serverChangeOf(/*knownThere=*/false, *current); + } + const auto known = entry.serverContent.fileState(upload.change.path); + if (!serverChanged(known, *current)) + return std::nullopt; + return serverChangeOf(known.has_value(), *current); } /** @@ -1856,76 +1903,120 @@ namespace FileTracking finishUpload(upload, UploadOutcome::NotUploaded); } - void askServerChanged(Upload upload) - { - waitingQuestions.push_back(std::move(upload)); - askNextServerChanged(); - } - - void askNextServerChanged() + /** + * @brief Lists the conflict @p upload raised, the server changed its destination as @p change tells, unless + * it no longer stands, see conflictStands(). The upload waits for the answer, see answerConflict(). + */ + void askServerChanged(Upload upload, ServerChange const& change) { - while (!openQuestion && !waitingQuestions.empty()) + if (conflictStands(upload)) { - auto upload = std::move(waitingQuestions.front()); - waitingQuestions.pop_front(); - const auto* entry = find(upload.watchId); - if (!entry) - continue; - // Nothing to ask about a path that moved meanwhile, it goes again at the new one. - if (entry->pendingUploads.movedAway(upload.change.path)) + auto added = conflicts.add(conflictOf(upload, change), std::move(upload)); + // PendingUploads lets one upload of a path wait at most, this is never expected. + if (added.replaced) { - enqueueUpload(std::move(upload)); - continue; - } - if (isGoneLocally(upload.change.path)) - { - onGoneLocally(*entry, upload); - continue; + Log::warn( + "FileTracking: the conflict about '{}' replaced another one", added.conflict.localPath.toUtf8() + ); + leaveOrder(*added.replaced); } - openQuestion = OpenQuestion{.questionId = Ids::generateQuestionId(), .upload = std::move(upload)}; - view->ask({.questionId = openQuestion->questionId, .remotePath = openQuestion->upload.destination}); + Log::info( + "FileTracking: '{}' changed on the server, waiting for an answer about it", + added.conflict.remotePath.value() + ); + view->showConflict(added.conflict); } progressChanged(); } - void answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer) + /** + * @brief Whether the conflict about @p upload still stands, checked when it is raised and once it is answered: + * a path that moved away meanwhile goes again at the new one, one that is gone locally has its deletion + * mirrored, and nothing is asked about a path that no longer is the watch's. Either ends @p upload. + */ + bool conflictStands(Upload const& upload) { - if (!openQuestion || openQuestion->questionId != questionId) - return; - auto upload = std::move(openQuestion->upload); - openQuestion.reset(); - progressChanged(); - switch (answer) + const auto* entry = find(upload.watchId); + if (!entry || entry->pendingUploads.movedAway(upload.change.path) || + !upload.change.path.isWithin(entry->localPath)) { - case SharedData::ServerChangedAnswer::Replace: - enqueueUpload(std::move(upload)); - break; - case SharedData::ServerChangedAnswer::Keep: - Log::info("FileTracking: kept the server's version of '{}'.", upload.destination.value()); - declineUpload(upload); - break; - case SharedData::ServerChangedAnswer::Dismissed: - return abandonServerChangedQuestions(upload); + finishUpload(upload, UploadOutcome::NotUploaded); + return false; + } + if (isGoneLocally(upload.change.path)) + { + onGoneLocally(*entry, upload); + return false; } - askNextServerChanged(); + return true; } - void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + void answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer) { Log::warn( - "FileTracking: answering conflict {} with {} is not implemented", - conflictId.value(), - Utility::enumToString(resolution) + "FileTracking: ignoring the answer {} to question {}, conflicts are answered instead", + Utility::enumToString(answer), + questionId.value() ); } + void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + { + auto listed = conflicts.remove(conflictId); + if (!listed) + { + Log::warn("FileTracking: there is no conflict {} to answer", conflictId.value()); + return; + } + resolveConflict(std::move(listed->context), resolution); + view->removeConflict(conflictId); + progressChanged(); + } + void answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) { - Log::warn( - "FileTracking: answering the conflicts of watch {} with {} is not implemented", + auto listed = conflicts.removeWatch(watchId); + if (listed.empty()) + { + Log::warn("FileTracking: watch {} has no conflicts to answer", watchId.value()); + return; + } + Log::info( + "FileTracking: answering {} conflict(s) of watch {} with {}", + listed.size(), watchId.value(), Utility::enumToString(resolution) ); + for (auto& conflict : listed) + { + resolveConflict(std::move(conflict.context), resolution); + view->removeConflict(conflict.conflict.conflictId); + } + progressChanged(); + } + + void resolveConflict(Upload upload, SharedData::ConflictResolution resolution) + { + switch (resolution) + { + case SharedData::ConflictResolution::ReplaceOnServer: + Log::info("FileTracking: replacing the server's version of '{}'.", upload.destination.value()); + return replaceOnServer(std::move(upload)); + case SharedData::ConflictResolution::KeepServers: + Log::info("FileTracking: kept the server's version of '{}'.", upload.destination.value()); + return declineUpload(upload); + } + } + + /** + * @brief Uploads @p upload over whatever the server has, once the path is checked again. + */ + void replaceOnServer(Upload upload) + { + if (!conflictStands(upload)) + return; + upload.checkServer = false; + checkServerThenUpload(std::move(upload)); } void retryFailure(Ids::FailureId const& failureId) @@ -1975,22 +2066,8 @@ namespace FileTracking } /** - * @brief Another dialog replaced the question for @p upload: it and the waiting questions are asked again on - * the next save, nothing opens now over the dialog that replaced it. - */ - void abandonServerChangedQuestions(Upload const& upload) - { - Log::info( - "FileTracking: the question about '{}' was replaced by another dialog.", upload.destination.value() - ); - declineUpload(upload); - for (auto const& waiting : std::exchange(waitingQuestions, {})) - declineUpload(waiting); - } - - /** - * @brief The change is not uploaded, and the changes that arrived while it was asked about wait for the next - * save. + * @brief The change is not uploaded, and the changes that arrived while its conflict waited wait for the next + * save, which raises it again. */ void declineUpload(Upload const& upload) { @@ -2020,13 +2097,13 @@ namespace FileTracking .refreshListing = upload.refreshListing, // Below the item a directory renamed without mirroring has no server counterpart yet. .createMissingDirectories = upload.change.path != entry->localPath, - .expectedDestination = std::move(expected), + .expectedDestination = expected, .continuesPartialOf = upload.continuesPartialOf, }, - [weak = weak_from_this(), upload](UploadEnd const& ended) + [weak = weak_from_this(), upload, expected](UploadEnd const& ended) { if (const auto self = weak.lock()) - self->onUploadEnded(upload, ended); + self->onUploadEnded(upload, expected, ended); }, {} ); @@ -2040,8 +2117,10 @@ namespace FileTracking /** * @brief Once an upload ended, what the server has now is the state the next check compares with. The upload * stays on its way until the remote told that state. + * @param expected What the upload expected the server to have at its destination. */ - void onUploadEnded(Upload const& upload, UploadEnd const& ended) + void + onUploadEnded(Upload const& upload, std::optional const& expected, UploadEnd const& ended) { auto* entry = find(upload.watchId); // The server's copy changed meanwhile and stayed, the upload asks about it now unless the user stopped @@ -2049,7 +2128,9 @@ namespace FileTracking if (entry && ended.changedOnServer) { if (entry->options.checkRemoteChanges) - return askServerChanged(upload); + return askServerChanged( + upload, serverChangeOf(expected && expected->file.has_value(), ended.destinationState) + ); return enqueueUpload(upload); } if (ended.reason != SharedData::OperationCompletionReason::Completed || !entry) diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index e6ef49ef..485bcf38 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -200,7 +200,7 @@ namespace Test void ask(SharedData::ServerChangedQuestion const& question) override { - questions.push_back(question); + ADD_FAILURE() << "the tracker lists conflicts, it asked about " << question.remotePath.value(); } void showOrphanedCount(std::size_t count) override @@ -216,6 +216,7 @@ namespace Test void showConflict(SharedData::WatchConflict const& conflict) override { conflicts.insert_or_assign(conflict.conflictId.value(), conflict); + shownConflicts.push_back(conflict); } void removeConflict(Ids::ConflictId const& conflictId) override @@ -248,11 +249,12 @@ namespace Test std::map entries{}; std::vector removed{}; std::vector ended{}; - std::vector questions{}; std::vector orphanedCounts{}; std::vector progress{}; /** @brief The conflicts shown and not removed, by id. */ std::map conflicts{}; + /** @brief Every conflict shown, in order. */ + std::vector shownConflicts{}; std::vector removedConflicts{}; /** @brief The failures shown and not removed, by id. */ std::map failures{}; @@ -420,7 +422,9 @@ namespace Test return operation.onUploadEnded({.reason = reason}); if (!holdsExpected(operation)) return operation.onUploadEnded( - {.reason = SharedData::OperationCompletionReason::Failed, .changedOnServer = true} + {.reason = SharedData::OperationCompletionReason::Failed, + .changedOnServer = true, + .destinationState = serverStateAt(operation.destination)} ); operation.onUploadEnded( {.reason = SharedData::OperationCompletionReason::Completed, @@ -658,6 +662,36 @@ namespace Test return view_->entries.begin()->second; } + SharedData::WatchConflict onlyConflict() + { + EXPECT_EQ(view_->conflicts.size(), 1u); + return view_->conflicts.empty() ? SharedData::WatchConflict{} : view_->conflicts.begin()->second; + } + + /** + * @brief The conflict the view shows about @p localPath, none when it shows none. + */ + std::optional shownConflictAt(std::filesystem::path const& localPath) const + { + for (auto const& conflict : std::views::values(view_->conflicts)) + if (conflict.localPath.native() == localPath) + return conflict; + return std::nullopt; + } + + /** + * @brief Runs the handlers until the view shows @p count conflicts. + */ + bool runUntilConflicts(std::size_t count) + { + return runUntil( + [this, count]() + { + return view_->conflicts.size() == count; + } + ); + } + /** * @brief The progress the view showed last, nothing on its way before it showed any. */ @@ -801,7 +835,7 @@ namespace Test return uploadsTo("/srv/notes.txt") == 1u; } )); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_TRUE(onlyEntry().uploading); const auto& expected = remote_->of("upload").back()->upload.expectedDestination; ASSERT_TRUE(expected.has_value()); @@ -835,12 +869,12 @@ namespace Test EXPECT_EQ(expected->file, kept); completeUploads(); runFor(std::chrono::milliseconds{200}); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(remote_->server.at("/srv/notes.txt"), stateOf(notes)); EXPECT_EQ(remote_->stats, 0u); } - TEST_F(TrackerTest, AServerCopyChangedMeanwhileIsAskedAboutOnceTheUploadFoundIt) + TEST_F(TrackerTest, AServerCopyChangedMeanwhileIsListedAsAConflictOnceTheUploadFoundIt) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); @@ -854,23 +888,29 @@ namespace Test return uploadsTo("/srv/notes.txt") == 1u; } )); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); completeUploads(); - ASSERT_TRUE(runUntil( - [this]() - { - return !view_->questions.empty(); - } - )); - EXPECT_EQ(view_->questions.back().remotePath.value(), "/srv/notes.txt"); + ASSERT_EQ(view_->conflicts.size(), 1u); + const auto conflict = onlyConflict(); + EXPECT_EQ(conflict.watchId, onlyEntry().watchId); + EXPECT_EQ(conflict.kind, SharedData::ConflictKind::ChangedOnServer); + EXPECT_EQ(conflict.localPath, opened->localPath); + EXPECT_EQ(conflict.remotePath.value(), "/srv/notes.txt"); + EXPECT_EQ(conflict.serverModifiedAt, std::optional{changedOnServer.mtime}); + EXPECT_EQ(conflict.localModifiedAt, std::optional{stateOf(opened->localPath.native()).mtime}); + EXPECT_GT(conflict.raisedAt, 0u); EXPECT_EQ(remote_->server.at("/srv/notes.txt"), changedOnServer); EXPECT_TRUE(onlyEntry().uploading); - tracker_->answer(view_->questions.back().questionId, SharedData::ServerChangedAnswer::Keep); + + tracker_->answerConflict(conflict.conflictId, SharedData::ConflictResolution::KeepServers); + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_EQ(view_->removedConflicts, std::vector{conflict.conflictId.value()}); runFor(std::chrono::milliseconds{200}); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); EXPECT_FALSE(onlyEntry().uploading); EXPECT_EQ(remote_->server.at("/srv/notes.txt"), changedOnServer); + // The server's version was kept, the next save raises the conflict again. write(opened->localPath.native(), "changed again"); ASSERT_TRUE(runUntil( [this]() @@ -879,22 +919,21 @@ namespace Test } )); completeUploads(); - ASSERT_TRUE(runUntil( - [this]() - { - return view_->questions.size() == 2u; - } - )); - tracker_->answer(view_->questions.back().questionId, SharedData::ServerChangedAnswer::Replace); + ASSERT_EQ(view_->conflicts.size(), 1u); + const auto again = onlyConflict(); + EXPECT_NE(again.conflictId, conflict.conflictId); + tracker_->answerConflict(again.conflictId, SharedData::ConflictResolution::ReplaceOnServer); + EXPECT_TRUE(view_->conflicts.empty()); ASSERT_EQ(uploadsTo("/srv/notes.txt"), 3u); EXPECT_FALSE(remote_->of("upload").back()->upload.expectedDestination.has_value()); completeUploads(); EXPECT_EQ(remote_->server.at("/srv/notes.txt"), stateOf(opened->localPath.native())); EXPECT_FALSE(onlyEntry().uploading); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(remote_->stats, 0u); } - TEST_F(TrackerTest, AFileTheServerLostIsAskedAboutBeforeItIsUploadedAgain) + TEST_F(TrackerTest, AFileTheServerLostIsListedAsDeletedOnTheServerBeforeItIsUploadedAgain) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); @@ -908,20 +947,19 @@ namespace Test } )); completeUploads(); - ASSERT_TRUE(runUntil( - [this]() - { - return !view_->questions.empty(); - } - )); + ASSERT_EQ(view_->conflicts.size(), 1u); + const auto conflict = onlyConflict(); + EXPECT_EQ(conflict.kind, SharedData::ConflictKind::DeletedOnServer); + EXPECT_FALSE(conflict.serverModifiedAt.has_value()); + EXPECT_TRUE(conflict.localModifiedAt.has_value()); EXPECT_FALSE(remote_->server.contains("/srv/notes.txt")); - tracker_->answer(view_->questions.back().questionId, SharedData::ServerChangedAnswer::Replace); + tracker_->answerConflict(conflict.conflictId, SharedData::ConflictResolution::ReplaceOnServer); ASSERT_EQ(uploadsTo("/srv/notes.txt"), 2u); completeUploads(); EXPECT_EQ(remote_->server.at("/srv/notes.txt"), stateOf(opened->localPath.native())); } - TEST_F(TrackerTest, AFileThatAppearedOnTheServerBeforeItsFirstUploadIsAskedAbout) + TEST_F(TrackerTest, AFileThatAppearedOnTheServerBeforeItsFirstUploadIsListedAsCreatedOnTheServer) { ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); // FSEvents also delivers the creation of src from just before the watch. Coming after new.cpp, it would @@ -947,13 +985,12 @@ namespace Test EXPECT_FALSE(expected->file.has_value()); remote_->server["/srv/project/src/new.cpp"] = {.mtime = 7u, .size = 9u}; completeUploads(); - ASSERT_TRUE(runUntil( - [this]() - { - return !view_->questions.empty(); - } - )); - EXPECT_EQ(view_->questions.back().remotePath.value(), "/srv/project/src/new.cpp"); + ASSERT_EQ(view_->conflicts.size(), 1u); + const auto conflict = onlyConflict(); + EXPECT_EQ(conflict.kind, SharedData::ConflictKind::CreatedOnServer); + EXPECT_EQ(conflict.remotePath.value(), "/srv/project/src/new.cpp"); + EXPECT_EQ(conflict.localPath.native(), root_ / "local" / "project" / "src" / "new.cpp"); + EXPECT_EQ(conflict.serverModifiedAt, std::optional{std::uint64_t{7u}}); EXPECT_EQ(remote_->server.at("/srv/project/src/new.cpp"), (FileTracking::ServerState{.mtime = 7u, .size = 9u})); } @@ -983,7 +1020,7 @@ namespace Test ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 0u})); completeUploads(); ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 1u})); - tracker_->answer(view_->questions.back().questionId, SharedData::ServerChangedAnswer::Keep); + tracker_->answerConflict(onlyConflict().conflictId, SharedData::ConflictResolution::KeepServers); ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 1u, .waitingForAnswer = 0u})); } @@ -1017,7 +1054,7 @@ namespace Test EXPECT_GE(lastProgress().done, 1u); } - TEST_F(TrackerTest, AnAnswerToAnotherQuestionChangesNothing) + TEST_F(TrackerTest, AnAnswerToAnUnknownConflictChangesNothing) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); @@ -1031,16 +1068,254 @@ namespace Test } )); completeUploads(); + ASSERT_EQ(view_->conflicts.size(), 1u); + tracker_->answerConflict(Ids::generateConflictId(), SharedData::ConflictResolution::ReplaceOnServer); + tracker_->answerAllConflicts(Ids::generateWatchId(), SharedData::ConflictResolution::ReplaceOnServer); + tracker_->answer(Ids::generateQuestionId(), SharedData::ServerChangedAnswer::Replace); + + EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); + EXPECT_TRUE(onlyEntry().uploading); + EXPECT_EQ(view_->conflicts.size(), 1u); + EXPECT_TRUE(view_->removedConflicts.empty()); + } + + /** + * @brief Uploads and watches the project with a second file beside main.cpp, then changes both on the server + * and locally: each upload finds the server's copy changed and lists a conflict, main.cpp's first. + */ + class TrackerConflictsTest : public TrackerTest + { + protected: + std::filesystem::path main_; + std::filesystem::path util_; + + void raiseTwoConflicts() + { + const auto source = root_ / "local" / "project" / "src"; + main_ = source / "main.cpp"; + util_ = source / "util.cpp"; + write(util_, "util"); + ASSERT_TRUE( + tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value() + ); + completeEverything(); + remote_->server["/srv/project/src/main.cpp"].mtime += 100u; + remote_->server["/srv/project/src/util.cpp"].mtime += 100u; + + write(main_, "changed main"); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/project/src/main.cpp") == 1u; + } + )); + write(util_, "changed util"); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/project/src/util.cpp") == 1u; + } + )); + completeUploads(); + ASSERT_EQ(view_->conflicts.size(), 2u); + ASSERT_TRUE(shownConflictAt(main_).has_value()); + ASSERT_TRUE(shownConflictAt(util_).has_value()); + } + + Ids::ConflictId conflictIdAt(std::filesystem::path const& localPath) const + { + return shownConflictAt(localPath).value_or(SharedData::WatchConflict{}).conflictId; + } + }; + + TEST_F(TrackerConflictsTest, TwoConflictsOfADirectoryWatchAreListedAtOnceAndReplaceAllUploadsBothInOrder) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto mainConflict = conflictIdAt(main_); + const auto utilConflict = conflictIdAt(util_); + EXPECT_NE(mainConflict, utilConflict); + EXPECT_EQ(view_->shownConflicts.front().conflictId, mainConflict); + EXPECT_TRUE(onlyEntry().uploading); + const auto uploadsBefore = remote_->of("upload").size(); + + tracker_->answerAllConflicts(onlyEntry().watchId, SharedData::ConflictResolution::ReplaceOnServer); + + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_EQ(view_->removedConflicts, (std::vector{mainConflict.value(), utilConflict.value()})); + const auto uploads = remote_->sourcesOf("upload"); + ASSERT_EQ(uploads.size(), uploadsBefore + 2u); + EXPECT_EQ(uploads[uploadsBefore], main_.string()); + EXPECT_EQ(uploads[uploadsBefore + 1u], util_.string()); + EXPECT_FALSE(lastUploadTo("/srv/project/src/main.cpp")->upload.expectedDestination.has_value()); + EXPECT_FALSE(lastUploadTo("/srv/project/src/util.cpp")->upload.expectedDestination.has_value()); + completeUploads(); + EXPECT_EQ(remote_->server.at("/srv/project/src/main.cpp"), stateOf(main_)); + EXPECT_EQ(remote_->server.at("/srv/project/src/util.cpp"), stateOf(util_)); + EXPECT_FALSE(onlyEntry().uploading); + } + + TEST_F(TrackerConflictsTest, KeepAllKeepsTheServersVersionsAndTheNextSaveAsksAgain) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto keptMain = remote_->server.at("/srv/project/src/main.cpp"); + const auto keptUtil = remote_->server.at("/srv/project/src/util.cpp"); + + tracker_->answerAllConflicts(onlyEntry().watchId, SharedData::ConflictResolution::KeepServers); + + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_EQ(view_->removedConflicts.size(), 2u); + runFor(std::chrono::milliseconds{200}); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); + EXPECT_EQ(uploadsTo("/srv/project/src/util.cpp"), 1u); + EXPECT_EQ(remote_->server.at("/srv/project/src/main.cpp"), keptMain); + EXPECT_EQ(remote_->server.at("/srv/project/src/util.cpp"), keptUtil); + EXPECT_FALSE(onlyEntry().uploading); + + write(main_, "changed main again"); ASSERT_TRUE(runUntil( [this]() { - return !view_->questions.empty(); + return uploadsTo("/srv/project/src/main.cpp") == 2u; } )); - tracker_->answer(Ids::generateQuestionId(), SharedData::ServerChangedAnswer::Replace); + completeUploads(); + ASSERT_EQ(view_->conflicts.size(), 1u); + EXPECT_EQ(onlyConflict().localPath.native(), main_); + } - EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); - EXPECT_TRUE(onlyEntry().uploading); + TEST_F(TrackerConflictsTest, ASingleReplaceAndASingleKeepAnswerOnlyTheirOwnConflict) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto keptUtil = remote_->server.at("/srv/project/src/util.cpp"); + + tracker_->answerConflict(conflictIdAt(main_), SharedData::ConflictResolution::ReplaceOnServer); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 2u); + EXPECT_EQ(uploadsTo("/srv/project/src/util.cpp"), 1u); + ASSERT_EQ(view_->conflicts.size(), 1u); + EXPECT_EQ(onlyConflict().localPath.native(), util_); + + tracker_->answerConflict(conflictIdAt(util_), SharedData::ConflictResolution::KeepServers); + EXPECT_TRUE(view_->conflicts.empty()); + completeUploads(); + runFor(std::chrono::milliseconds{200}); + EXPECT_EQ(uploadsTo("/srv/project/src/util.cpp"), 1u); + EXPECT_EQ(remote_->server.at("/srv/project/src/main.cpp"), stateOf(main_)); + EXPECT_EQ(remote_->server.at("/srv/project/src/util.cpp"), keptUtil); + EXPECT_FALSE(onlyEntry().uploading); + } + + TEST_F(TrackerConflictsTest, StoppingAWatchDropsItsConflictsAndTellsTheView) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto mainConflict = conflictIdAt(main_); + const auto utilConflict = conflictIdAt(util_); + + tracker_->stop(onlyEntry().watchId); + + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_EQ(view_->removedConflicts, (std::vector{mainConflict.value(), utilConflict.value()})); + tracker_->answerConflict(mainConflict, SharedData::ConflictResolution::ReplaceOnServer); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); + + // The uploads that waited left the server order: a new watch of the same path goes at once. + ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + EXPECT_EQ(uploadsTo("/srv/project"), 2u); + } + + TEST_F(TrackerTest, StoppingADownloadedCopyWithAConflictFreesItsServerPath) + { + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + remote_->server["/srv/notes.txt"].mtime += 100u; + write(opened->localPath.native(), "changed locally"); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/notes.txt") == 1u; + } + )); + completeUploads(); + const auto conflictId = onlyConflict().conflictId; + + tracker_->stop(onlyEntry().watchId); + EXPECT_EQ(view_->removedConflicts, std::vector{conflictId.value()}); + + const auto reopened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(reopened.has_value()); + write(reopened->localPath.native(), "changed in the new copy"); + EXPECT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/notes.txt") == 2u; + } + )); + } + + TEST_F(TrackerConflictsTest, StoppingAllDropsTheConflictsWithoutTellingTheView) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + + tracker_->stopAll(); + + EXPECT_EQ(view_->conflicts.size(), 2u); + EXPECT_TRUE(view_->removedConflicts.empty()); + tracker_->answerAllConflicts( + view_->conflicts.begin()->second.watchId, SharedData::ConflictResolution::ReplaceOnServer + ); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); + ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + EXPECT_EQ(uploadsTo("/srv/project"), 2u); + } + + TEST_F(TrackerConflictsTest, ALostConnectionDropsTheConflictsAndTellsTheView) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto mainConflict = conflictIdAt(main_); + const auto utilConflict = conflictIdAt(util_); + + tracker_->connectionLost(); + + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_EQ(view_->removedConflicts, (std::vector{mainConflict.value(), utilConflict.value()})); + EXPECT_EQ(lastProgress().waitingForAnswer, 0u); + } + + TEST_F(TrackerConflictsTest, AConflictWhosePathWasDeletedLocallyBeforeTheAnswerUploadsNothing) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + std::filesystem::remove(main_); + // The deletion stops the upload, which already ended, and waits for its answer. + ASSERT_TRUE(runUntilStopped()); + EXPECT_TRUE(remote_->of("remove").empty()); + + tracker_->answerConflict(conflictIdAt(main_), SharedData::ConflictResolution::ReplaceOnServer); + + EXPECT_FALSE(shownConflictAt(main_).has_value()); + EXPECT_EQ(view_->conflicts.size(), 1u); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); + // Like any deletion, it goes to the server. + EXPECT_EQ(remote_->sourcesOf("remove"), std::vector{"/srv/project/src/main.cpp"}); + } + + TEST_F(TrackerConflictsTest, AConflictWhoseFileMovedAwayBeforeTheAnswerGoesToItsNewName) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto renamed = main_.parent_path() / "renamed.cpp"; + std::filesystem::rename(main_, renamed); + ASSERT_TRUE(runUntilStopped()); + + tracker_->answerConflict(conflictIdAt(main_), SharedData::ConflictResolution::ReplaceOnServer); + + EXPECT_FALSE(shownConflictAt(main_).has_value()); + EXPECT_EQ(view_->conflicts.size(), 1u); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); + // The server's copy follows the rename, then the file goes over it. + ASSERT_EQ(remote_->of("rename").size(), 1u); + EXPECT_EQ(remote_->of("rename").front()->source, "/srv/project/src/main.cpp"); + EXPECT_EQ(remote_->of("rename").front()->destination, "/srv/project/src/renamed.cpp"); + completeRemovesAndRenames(); + ASSERT_EQ(uploadsTo("/srv/project/src/renamed.cpp"), 1u); + EXPECT_FALSE(lastUploadTo("/srv/project/src/renamed.cpp")->upload.expectedDestination.has_value()); } TEST_F(TrackerTest, UploadAndWatchUploadsTheCanonicalItemFirst) @@ -1129,7 +1404,7 @@ namespace Test )); runFor(std::chrono::milliseconds{500}); EXPECT_TRUE(remote_->of("remove").empty()); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(onlyEntry().name, "notes.txt"); EXPECT_EQ(onlyEntry().remotePath.value(), "/srv/notes.txt"); const auto renames = remote_->of("rename"); @@ -1582,11 +1857,11 @@ namespace Test ASSERT_TRUE(runUntil( [this]() { - return uploadsTo("/srv/project/src/main.cpp") == 1u || !view_->questions.empty(); + return uploadsTo("/srv/project/src/main.cpp") == 1u || !view_->conflicts.empty(); } )); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_TRUE(remote_->of("remove").empty()); } @@ -1606,11 +1881,11 @@ namespace Test write(file, "int main() { return 1; }"); runFor(std::chrono::milliseconds{1000}); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 0u); completeRemovesAndRenames(); EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, TheDeleteOfADirectoryWaitsForTheDeletesOfItsContent) @@ -1897,7 +2172,7 @@ namespace Test EXPECT_FALSE(remote_->server.contains("/srv/project/src/x/main.c")); EXPECT_FALSE(remote_->server.contains("/srv/project/src/x")); EXPECT_TRUE(remote_->server.contains("/srv/project/docs/x/y/deep.txt")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); #ifndef __APPLE__ // The watch reports the move between directories as one, the server renames the directory. ASSERT_EQ(remote_->of("rename").size(), 1u); @@ -1927,7 +2202,7 @@ namespace Test EXPECT_FALSE(remote_->server.contains("/srv/project/src/main.cpp")); EXPECT_TRUE(remote_->server.contains("/srv/project/lib/main.cpp")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); #ifndef __APPLE__ ASSERT_EQ(remote_->of("rename").size(), 1u); EXPECT_EQ(remote_->of("rename").front()->source, "/srv/project/src/main.cpp"); @@ -1992,12 +2267,12 @@ namespace Test ASSERT_TRUE(runUntil( [this]() { - return !remote_->of("remove").empty() || !view_->questions.empty(); + return !remote_->of("remove").empty() || !view_->conflicts.empty(); } )); runFor(std::chrono::milliseconds{500}); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(remote_->sourcesOf("remove"), std::vector{"/srv/project/src/main.cpp"}); EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 0u); } @@ -2028,7 +2303,7 @@ namespace Test if (operation.source == "/srv/project/kind" || operation.destination == "/srv/project/kind") atKind.push_back(operation.kind); EXPECT_EQ(atKind, (std::vector{"upload", "remove", "upload"})); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); EXPECT_TRUE(remote_->server.contains("/srv/project/kind/inside.txt")); } @@ -2060,7 +2335,7 @@ namespace Test EXPECT_EQ(uploadsTo("/srv/project/dir"), 1u); EXPECT_EQ(uploadsTo("/srv/project/dir/a.txt"), 1u); EXPECT_TRUE(remote_->server.contains("/srv/project/dir/a.txt")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, ADirectoryRemovedJustAfterItsUploadSentItLeavesNothingOnTheServer) @@ -2095,7 +2370,7 @@ namespace Test const auto removed = remote_->sourcesOf("remove"); ASSERT_FALSE(removed.empty()); EXPECT_EQ(removed.back(), "/srv/project/moved-dir"); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, ADirectoryRemovedWhileItUploadsLeavesNothingOfWhatTheUploadSent) @@ -2132,7 +2407,7 @@ namespace Test const auto removed = remote_->sourcesOf("remove"); ASSERT_FALSE(removed.empty()); EXPECT_EQ(removed.back(), "/srv/project/moved-dir"); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, AnEmptyDirectoryAnUploadLeftAfterItsFilesWentIsDeletedOnTheServer) @@ -2159,7 +2434,7 @@ namespace Test completeEverything(); EXPECT_FALSE(remote_->server.contains("/srv/project/moved-dir")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, AFileDeletedWhileItsDirectoryUploadsIsDeletedOnTheServerIfTheUploadSentIt) @@ -2192,7 +2467,7 @@ namespace Test EXPECT_EQ(remote_->sourcesOf("remove"), std::vector{"/srv/project/moved-in/gone.txt"}); EXPECT_TRUE(remote_->server.contains("/srv/project/moved-in/keep.txt")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, ALinkUploadedWithItsDirectoryIsDeletedOnTheServerToo) @@ -2261,11 +2536,11 @@ namespace Test ASSERT_TRUE(runUntil( [this]() { - return uploadsTo("/srv/project/in") == 1u || !view_->questions.empty(); + return uploadsTo("/srv/project/in") == 1u || !view_->conflicts.empty(); } )); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, AnOpenedCopyIsNotUploadedBackUnchanged) @@ -2367,7 +2642,7 @@ namespace Test completeEverything(); EXPECT_EQ(remote_->server.at("/srv/project/new/one.txt"), stateOf(project / "new" / "one.txt")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, ASubdirectoryReportedWhileItsParentUploadsGoesWithIt) @@ -2599,7 +2874,7 @@ namespace Test EXPECT_TRUE(remote_->of("remove").empty()); EXPECT_TRUE(remote_->of("discard").empty()); EXPECT_FALSE(remote_->server.contains("/srv/project/big.bin")); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, AKnownFileDeletedWhileItsChangeUploadsStopsItThenDeletesTheServerCopy) @@ -2828,7 +3103,7 @@ namespace Test EXPECT_EQ(uploadsTo("/srv/project/huge.bin"), 1u); EXPECT_FALSE(remote_->server.contains("/srv/project/huge.bin")); EXPECT_EQ(remote_->server.at("/srv/project/renamed.bin"), stateOf(renamed)); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, AKnownFileRenamedWhileItsChangeUploadsRenamesTheServerCopyThenContinues) @@ -2872,7 +3147,7 @@ namespace Test completeEverything(); EXPECT_FALSE(remote_->server.contains("/srv/project/src/main.cpp")); EXPECT_EQ(remote_->server.at("/srv/project/src/start.cpp"), stateOf(renamed)); - EXPECT_TRUE(view_->questions.empty()); + EXPECT_TRUE(view_->conflicts.empty()); } TEST_F(TrackerTest, AFileRenamedTwiceWhileItUploadsContinuesUnderTheLastName) From 1945496adc7a18a79c07798f9b5ccf506224d9f9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:28:21 +0200 Subject: [PATCH 038/114] Forwarded the dock tab attention to the content panel manager --- .../file_tracking/dock_tab_attention.cpp | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp b/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp index 1c79ce82..0cf6c38f 100644 --- a/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp +++ b/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp @@ -1,9 +1,46 @@ #include +#include +#include +#include + +#include + +#include +#include +#include #include namespace FileTracking { + namespace + { + std::string conflictsTitle(std::uint64_t count) + { + if (count == 0u) + return {}; + if (count == 1u) + return language->get("fileTracking", "attentionConflictsOne"); + return fmt::format(fmt::runtime(language->get("fileTracking", "attentionConflicts")), count); + } + + std::string failedTitle(std::uint64_t count) + { + if (count == 0u) + return {}; + if (count == 1u) + return language->get("fileTracking", "attentionFailedOne"); + return fmt::format(fmt::runtime(language->get("fileTracking", "attentionFailed")), count); + } + + std::chrono::milliseconds steadyNow() + { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch() + ); + } + } + struct DockTabAttention::Implementation { explicit Implementation(std::string panelId) @@ -12,6 +49,24 @@ namespace FileTracking std::string panelId; AttentionState state{}; + + /** + * @brief Hands @p attention to the dock tab of this session's panel. + */ + void forward(Attention const& attention) const + { + auto manager = Nui::val::global("contentPanelManager"); + if (manager.isUndefined() || manager["setFileTrackingAttention"].isUndefined()) + return Log::warn("FileTracking: contentPanelManager.setFileTrackingAttention is missing"); + + auto shown = Nui::val::object(); + shown.set("conflicts", static_cast(attention.counts.conflicts)); + shown.set("failed", static_cast(attention.counts.failed)); + shown.set("pulse", attention.pulse); + shown.set("conflictsText", conflictsTitle(attention.counts.conflicts)); + shown.set("failedText", failedTitle(attention.counts.failed)); + manager.call("setFileTrackingAttention", panelId, shown); + } }; DockTabAttention::DockTabAttention(std::string panelId) @@ -20,8 +75,11 @@ namespace FileTracking ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(DockTabAttention); - void DockTabAttention::show([[maybe_unused]] AttentionCounts const& counts) - {} + void DockTabAttention::show(AttentionCounts const& counts) + { + if (const auto attention = impl_->state.update(counts, steadyNow()); attention) + impl_->forward(*attention); + } bool DockTabAttention::unseen() const { @@ -29,8 +87,13 @@ namespace FileTracking } void DockTabAttention::acknowledge() - {} + { + impl_->state.acknowledge(); + } void DockTabAttention::reset() - {} + { + impl_->state.reset(); + impl_->forward(Attention{}); + } } From 9811e4f04829f29254c5a5528b38edc55b229c36 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:30:53 +0200 Subject: [PATCH 039/114] Rewrote the file tracking panel as the outliner and detail facade - Owns the store, the tracker client, the dock tab attention and the actions - Asks before deleting orphaned copies, toasts a failed delete and an overlap end - Removed the server changed dialog, the table and its row order --- .../session_components/file_tracking.hpp | 26 +- .../frontend/session_components/row_order.hpp | 54 -- frontend/source/frontend/session.cpp | 5 +- .../session_components/file_tracking.cpp | 894 +++++++----------- frontend/test/frontend/main.cpp | 1 - frontend/test/frontend/test_row_order.hpp | 33 - 6 files changed, 354 insertions(+), 659 deletions(-) delete mode 100644 frontend/include/frontend/session_components/row_order.hpp delete mode 100644 frontend/test/frontend/test_row_order.hpp diff --git a/frontend/include/frontend/session_components/file_tracking.hpp b/frontend/include/frontend/session_components/file_tracking.hpp index d448e0fd..12cdcd2e 100644 --- a/frontend/include/frontend/session_components/file_tracking.hpp +++ b/frontend/include/frontend/session_components/file_tracking.hpp @@ -13,13 +13,22 @@ #include /** - * @brief Shows the file tracker of a session, which lives in the backend as "FileTracker::": its watches, - * its questions and the orphaned download directories. Forwards what the user asks for and decides nothing. + * @brief The File Tracking panel of a session: the outliner of its watches and orphaned copies beside the detail of + * the selected one. Shows the file tracker that lives in the backend as "FileTracker::", forwards + * what the user asks for and decides nothing. Also drives the attention of its dock tab. */ class FileTrackingPanel { public: - explicit FileTrackingPanel(ConfirmDialog* confirmDialog); + struct Params + { + /** @brief Asks before deleting orphaned copies and shows errors of opening local items. */ + ConfirmDialog* confirmDialog = nullptr; + /** @brief The session's layout id, naming its dock panel for the tab attention. */ + std::string sessionLayoutId = {}; + }; + + explicit FileTrackingPanel(Params params); ROAR_PIMPL_SPECIAL_FUNCTIONS(FileTrackingPanel); /** @@ -73,6 +82,17 @@ class FileTrackingPanel */ void discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath); + /** + * @brief What Reveal on Server does: shows @p path in the session's remote file explorer side. + */ + void setRevealOnServer(std::function revealOnServer); + + /** + * @brief The user activated the dock tab: jumps to the newest conflict when conflicts arrived since the last + * jump. Does not sync. + */ + void onTabActivated(); + Nui::ElementRenderer operator()(); private: diff --git a/frontend/include/frontend/session_components/row_order.hpp b/frontend/include/frontend/session_components/row_order.hpp deleted file mode 100644 index e4bd7384..00000000 --- a/frontend/include/frontend/session_components/row_order.hpp +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace FileTracking -{ - /** - * @brief The keys of a table's rows in the order they were added, so the row of a key can be found. - */ - template - class RowOrder - { - public: - /** - * @brief Appends the row of @p key. - */ - void add(KeyT key) - { - keys_.push_back(std::move(key)); - } - - /** - * @brief Forgets the row of @p key. - * @return The index the row had, nothing when @p key has no row. - */ - std::optional remove(KeyT const& key) - { - const auto found = std::ranges::find(keys_, key); - if (found == keys_.end()) - return std::nullopt; - const auto index = static_cast(std::distance(keys_.begin(), found)); - keys_.erase(found); - return index; - } - - void clear() - { - keys_.clear(); - } - - std::size_t size() const - { - return keys_.size(); - } - - private: - std::vector keys_{}; - }; -} diff --git a/frontend/source/frontend/session.cpp b/frontend/source/frontend/session.cpp index ab21e80a..60ad2f0e 100644 --- a/frontend/source/frontend/session.cpp +++ b/frontend/source/frontend/session.cpp @@ -162,7 +162,10 @@ struct Session::Implementation , operationQueue{this->stateHolder, this->events, this->persistenceName, this->engineOptions.queueOptions.value(), this->confirmDialog, &fileExplorerPanel.localSideModel(), fileExplorerPanel.remoteSideModel()} , frontendSessionManager{} , sessionOptions{params.stateHolder, params.events, this->persistenceName, this->sessionLayoutId, params.confirmDialog} - , fileTrackingPanel{params.confirmDialog} + , fileTrackingPanel{FileTrackingPanel::Params{ + .confirmDialog = params.confirmDialog, + .sessionLayoutId = this->sessionLayoutId, + }} , commandHistoryPanel{ params.commandStoreClient, params.events, diff --git a/frontend/source/frontend/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index a1fab749..82c9891b 100644 --- a/frontend/source/frontend/session_components/file_tracking.cpp +++ b/frontend/source/frontend/session_components/file_tracking.cpp @@ -1,280 +1,85 @@ #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include -#include #include -#include -#include -#include #include #include -#include -#include -#include -#include #include -#include +#include #include -#include -#include +#include #include #include +#include #include -#include #include #include #include #include -#include #include -#include +#include struct FileTrackingPanel::Implementation { - /** - * @brief What the row of one watch shows, shared with its cells. - */ - struct Row - { - std::shared_ptr> name{std::make_shared>()}; - std::shared_ptr> remotePath{std::make_shared>()}; - std::shared_ptr> uploading{std::make_shared>(false)}; - std::shared_ptr> autoReupload{std::make_shared>(false)}; - std::shared_ptr> moveRemote{std::make_shared>(false)}; - std::shared_ptr> deleteRemote{std::make_shared>(false)}; - std::shared_ptr> checkRemote{std::make_shared>(false)}; - std::shared_ptr> downloadedCopy{std::make_shared>(false)}; - - void show(SharedData::TrackedEntry const& entry) - { - *name = entry.name; - *remotePath = entry.remotePath.value(); - *uploading = entry.uploading; - *autoReupload = entry.options.autoReupload; - *moveRemote = entry.options.moveRemoteOnLocalMove; - *deleteRemote = entry.options.deleteRemoteOnLocalDelete; - *checkRemote = entry.options.checkRemoteChanges; - *downloadedCopy = entry.downloadedCopy; - } - }; - - ConfirmDialog* confirmDialog; - - Ids::SessionId sessionId{}; - std::map rows{}; - FileTracking::RowOrder rowOrder{}; - Nui::Observed entryCount{std::size_t{0}}; - Nui::Observed orphanedCount{-1}; - Nui::Observed cleanupRunning{false}; - Nui::Observed progress{}; - std::vector listeners{}; - - std::shared_ptr table{std::make_shared( - ScriptNuiComponents::ResizableTable::HeaderRow{ - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnName")}, - .initialWidth = 0, - .resizeable = true, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnRemotePath")}, - .initialWidth = 0, - .resizeable = true, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnAutoReupload")}, - .initialWidth = 120, - .resizeable = false, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnMoveRemote")}, - .initialWidth = 120, - .resizeable = false, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnDeleteRemote")}, - .initialWidth = 120, - .resizeable = false, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnCheckRemote")}, - .initialWidth = 120, - .resizeable = false, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{language->get("fileTracking", "columnStatus")}, - .initialWidth = 100, - .resizeable = false, - }, - ScriptNuiComponents::ResizableTable::HeaderTableCell{ - .content = std::string{}, - .initialWidth = 60, - .resizeable = false, - }, - }, - std::nullopt - )}; - - Utility::LifetimeToken lifetime{}; - - explicit Implementation(ConfirmDialog* confirmDialog) - : confirmDialog{confirmDialog} - {} - - /** - * @brief Shows what RPC replies and tracker events changed, they arrive outside of DOM event handlers. - */ - static void syncView() - { - Nui::globalEventContext.executeActiveEventsImmediately(); - } - - /** - * @brief Yes replaces the server's version, No keeps it, no button means another dialog took the question's place. - */ - static SharedData::ServerChangedAnswer answerOf(std::optional const& button) + explicit Implementation(Params params) + : confirmDialog{params.confirmDialog} + , store{FileTracking::translatedTexts()} + , client{ + store, + [](SharedData::EndedWatches const& ended) + { + notifyEnded(ended); + } + } + , attention{std::move(params.sessionLayoutId)} { - if (!button) - return SharedData::ServerChangedAnswer::Dismissed; - return *button == ConfirmDialog::Button::Yes ? SharedData::ServerChangedAnswer::Replace - : SharedData::ServerChangedAnswer::Keep; - } - - static bool succeeded(Nui::val const& response) - { - return response.hasOwnProperty("success") && response["success"].as(); - } - - /** - * @brief The translated error of a failed reply, nothing when it has none. - */ - static std::optional errorOf(Nui::val const& response) - { - if (!response.hasOwnProperty("error")) - return std::nullopt; - return language->translate(response["error"].as()); - } - - std::string rpcName(std::string_view function) const - { - return fmt::format("FileTracker::{}::{}", sessionId.value(), function); - } - - /** - * @brief Forwards @p function with @p arguments to the tracker, a refusal is only logged. - */ - template - void forward(std::string_view function, ArgumentsT&&... arguments) - { - Nui::RpcClient::callWithBackChannel( - rpcName(function), - [function = std::string{function}](Nui::val response) + store.onAttentionChanged( + [this](FileTracking::AttentionCounts const& counts) { - if (!succeeded(response)) - Log::error("FileTracker::{} failed: {}", function, errorOf(response).value_or("")); - }, - std::forward(arguments)... + attention.show(counts); + } ); + actions = makeActions(); } - void listen() - { - listeners.push_back(Nui::RpcClient::autoRegisterFunction( - rpcName("onEntry"), - lifetime.guarded( - [this](SharedData::TrackedEntry const& entry) - { - showEntry(entry); - syncView(); - } - ) - )); - listeners.push_back(Nui::RpcClient::autoRegisterFunction( - rpcName("onEntryRemoved"), - lifetime.guarded( - [this](std::string const& watchId) - { - removeEntry(Ids::makeWatchId(watchId)); - syncView(); - } - ) - )); - listeners.push_back(Nui::RpcClient::autoRegisterFunction( - rpcName("onEntriesEnded"), - lifetime.guarded( - [](SharedData::EndedWatches const& ended) - { - notifyEnded(ended); - } - ) - )); - listeners.push_back(Nui::RpcClient::autoRegisterFunction( - rpcName("onQuestion"), - lifetime.guarded( - [this](SharedData::ServerChangedQuestion const& question) - { - ask(question); - } - ) - )); - listeners.push_back(Nui::RpcClient::autoRegisterFunction( - rpcName("onProgress"), - lifetime.guarded( - [this](SharedData::TrackerProgress const& shown) - { - progress = shown; - syncView(); - } - ) - )); - listeners.push_back(Nui::RpcClient::autoRegisterFunction( - rpcName("onOrphanedCount"), - lifetime.guarded( - [this](std::uint64_t count) - { - orphanedCount = static_cast(count); - syncView(); - } - ) - )); - } - - void showEntry(SharedData::TrackedEntry const& entry) - { - auto [row, inserted] = rows.try_emplace(entry.watchId); - row->second.show(entry); - if (!inserted) - return; - table->addRow(buildRow(entry.watchId, row->second)); - rowOrder.add(entry.watchId); - entryCount = rows.size(); - } - - void removeEntry(Ids::WatchId const& watchId) - { - rows.erase(watchId); - if (const auto row = rowOrder.remove(watchId)) - table->remove(*row); - entryCount = rows.size(); - } + ConfirmDialog* confirmDialog; + FileTracking::TrackingStore store; + FileTracking::TrackerClient client; + FileTracking::DockTabAttention attention; + FileTracking::TrackingActions actions{}; + std::function revealOnServer{}; + Utility::LifetimeToken lifetime{}; /** - * @brief Tells the user why watches ended that they did not stop, their rows are gone already. + * @brief Tells the user why watches ended that they did not stop. */ static void notifyEnded(SharedData::EndedWatches const& ended) { + if (ended.entries.empty()) + return; const auto text = [&ended]() { switch (ended.reason) { case SharedData::WatchEndReason::CopyGone: return language->get("fileTracking", "watchEndedCopyGone"); + case SharedData::WatchEndReason::Overlap: + return language->get("fileTracking", "watchEndedOverlap"); case SharedData::WatchEndReason::ConnectionLost: break; } @@ -290,337 +95,310 @@ struct FileTrackingPanel::Implementation Notifications::warning(fmt::format(fmt::runtime(text), fmt::join(names, ", "))); } - void ask(SharedData::ServerChangedQuestion const& question) + /** + * @brief Opens the copy @p localPath of @p instanceId with its application, discarding it when the user declines + * to open a risky file, like Open and Watch in the file explorer. + */ + void openCopy(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) { - confirmDialog->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Warning, - .headerText = language->get("fileTracking", "serverChangedHeader"), - .text = fmt::format( - fmt::runtime(language->get("fileTracking", "serverChangedText")), question.remotePath.value() - ), - .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, - .focusButton = ConfirmDialog::Button::No, - .onClose = lifetime.guarded( - [this, questionId = question.questionId](std::optional button) + FileExplorer::openLocalFile( + lifetime, + *confirmDialog, + localPath, + false, + lifetime.guarded( + [this, instanceId, localPath]() { - forward("answer", questionId.value(), Utility::enumToString(answerOf(button))); + client.discard(instanceId, localPath); } - ), - }); + ) + ); } - void refreshOrphanedCount() + /** + * @brief Opens the server file of the stopped copy @p watchId again and watches it, then removes the stopped one. + */ + void openAgain(Ids::WatchId const& watchId) { - Nui::RpcClient::callWithBackChannel( - "FileTracking::countOrphaned", + auto const* const entry = store.model().entryOf(watchId); + if (entry == nullptr) + return; + client.open( + entry->remotePath, + 0u, + true, + lifetime.guarded( + [this, watchId](Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) + { + client.removeStopped(watchId); + openCopy(instanceId, localPath); + } + ), lifetime.guarded( - [this](Nui::val response) + [](std::string const& reason) { - if (!succeeded(response)) - return Log::error("FileTracking: countOrphaned failed: {}", errorOf(response).value_or("")); - orphanedCount = response["count"].as(); - syncView(); + Notifications::error(reason); } ) ); } - void triggerCleanup() + void deleteOrphanedCopy(Ids::InstanceId const& instanceId) { - cleanupRunning = true; - Nui::RpcClient::callWithBackChannel( - "FileTracking::forceCleanup", - lifetime.guarded( - [this](Nui::val response) + auto const* const copy = store.model().orphanOf(instanceId); + if (copy == nullptr) + return; + confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("fileTracking", "confirmDeleteOrphanHeader"), + .text = fmt::format(fmt::runtime(language->get("fileTracking", "confirmDeleteOrphanText")), copy->name), + .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, + .focusButton = ConfirmDialog::Button::No, + .onClose = lifetime.guarded( + [this, instanceId, name = copy->name](std::optional button) { - cleanupRunning = false; - if (succeeded(response)) - { - Log::info( - "FileTracking: forceCleanup removed {} orphaned instance(s)", response["removed"].as() - ); - refreshOrphanedCount(); - } - else - Log::error("FileTracking: forceCleanup failed: {}", errorOf(response).value_or("")); - syncView(); + if (button != ConfirmDialog::Button::Yes) + return; + client.removeOrphanedCopy( + instanceId, + lifetime.guarded( + [name](std::string const& error) + { + Notifications::error(fmt::format( + fmt::runtime(language->get("fileTracking", "deleteOrphanFailed")), + name, + language->translate(error) + )); + } + ) + ); } - ) - ); + ), + }); } /** - * @brief How far the tracker is with the changes on their way, apart from the operation queue. Stays mounted, so - * the bar moves smoothly, and is hidden while nothing is on its way. + * @brief Asks before deleting every listed orphaned copy; a single one is asked for by its name. */ - Nui::ElementRenderer renderProgress() + void deleteAllOrphanedCopies() { - using namespace Nui::Elements; - using namespace Nui::Attributes; - using Nui::Elements::div; - using Nui::Elements::span; - - // clang-format off - return div{ - class_ = Nui::observe(progress).generate([this]() - { - auto const& shown = progress.value(); - const bool active = shown.pending > 0 || shown.waitingForAnswer > 0; - return std::string{active ? "file-tracking-progress" : "file-tracking-progress file-tracking-progress-idle"}; - }), - }( - div{class_ = "file-tracking-progress-track"}( - div{ - class_ = "file-tracking-progress-fill", - style = Nui::observe(progress).generate([this]() - { - auto const& shown = progress.value(); - const auto total = shown.pending + shown.done; - return fmt::format("width: {}%", total == 0 ? 0 : shown.done * 100 / total); - }), - }() + const auto orphans = store.model().members(FileTracking::OutlinerGroup::Orphaned, ""); + if (orphans.empty()) + return; + if (orphans.size() == 1u) + { + if (auto const* const instanceId = std::get_if(&orphans.front()); instanceId != nullptr) + deleteOrphanedCopy(*instanceId); + return; + } + confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("fileTracking", "confirmDeleteAllOrphansHeader"), + .text = fmt::format( + fmt::runtime(language->get("fileTracking", "confirmDeleteAllOrphansText")), orphans.size() ), - span{class_ = "file-tracking-progress-text"}( - Nui::observe(progress).generate([this]() -> Nui::ElementRenderer + .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, + .focusButton = ConfirmDialog::Button::No, + .onClose = lifetime.guarded( + [this](std::optional button) { - auto const& shown = progress.value(); - return Nui::Elements::text{fmt::format( - fmt::runtime(language->get("fileTracking", "progressSyncing")), - shown.done, - shown.pending + shown.done - )}(); - }) + if (button == ConfirmDialog::Button::Yes) + client.removeAllOrphanedCopies(); + } ), - span{ - class_ = Nui::observe(progress).generate([this]() - { - return std::string{progress.value().waitingForAnswer > 0 - ? "file-tracking-progress-waiting" - : "file-tracking-progress-waiting file-tracking-progress-idle"}; - }), - }( - Nui::observe(progress).generate([this]() -> Nui::ElementRenderer - { - return Nui::Elements::text{fmt::format( - fmt::runtime(language->get("fileTracking", "progressWaitingForAnswer")), - progress.value().waitingForAnswer - )}(); - }) - ) + }); + } + + FileTracking::TrackingActions makeActions() + { + return { + .answerConflict = + [this](Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + { + client.answerConflict(conflictId, resolution); + }, + .answerAllConflicts = + [this](Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) + { + client.answerAllConflicts(watchId, resolution); + }, + .retryFailure = + [this](Ids::FailureId const& failureId) + { + client.retryFailure(failureId); + }, + .dismissFailure = + [this](Ids::FailureId const& failureId) + { + client.dismissFailure(failureId); + }, + .retryAllFailures = + [this](Ids::WatchId const& watchId) + { + client.retryAllFailures(watchId); + }, + .dismissAllFailures = + [this](Ids::WatchId const& watchId) + { + client.dismissAllFailures(watchId); + }, + .setOption = + [this](Ids::WatchId const& watchId, SharedData::WatchOption option, bool enabled) + { + client.setOption(watchId, option, enabled); + }, + .stop = + [this](Ids::WatchId const& watchId) + { + client.stop(watchId); + }, + .removeStopped = + [this](Ids::WatchId const& watchId) + { + client.removeStopped(watchId); + }, + .removeAllStopped = + [this]() + { + client.removeAllStopped(); + }, + .openAgain = + [this](Ids::WatchId const& watchId) + { + openAgain(watchId); + }, + .showWatch = + [this](Ids::WatchId const& otherWatchId) + { + store.select(otherWatchId); + }, + .deleteOrphanedCopy = + [this](Ids::InstanceId const& instanceId) + { + deleteOrphanedCopy(instanceId); + }, + .deleteAllOrphanedCopies = + [this]() + { + deleteAllOrphanedCopies(); + }, + .openLocally = + [this](Paths::LocalPath const& path) + { + FileExplorer::openLocalFile(lifetime, *confirmDialog, path, false); + }, + .revealOnServer = + [this](Paths::RemotePath const& path) + { + if (revealOnServer) + revealOnServer(path); + }, + .select = + [this](FileTracking::Selection const& selection) + { + store.select(selection); + }, + .moveSelection = + [this](FileTracking::SelectionMove move) + { + store.moveSelection(move); + }, + .chooseTab = + [this](FileTracking::DetailTab tab) + { + store.setDetailTab(tab); + }, + .filter = + [this](std::string const& filter) + { + store.setFilter(filter); + }, + .setGroupExpanded = + [this](FileTracking::OutlinerGroup group, bool expanded) + { + store.setGroupExpanded(group, expanded); + }, + }; + } + + /** + * @return Whether the tracker lists nothing at all, whatever the filter hides. + */ + bool nothingTracked() const + { + return std::ranges::all_of( + FileTracking::outlinerGroupOrder, + [this](FileTracking::OutlinerGroup group) + { + return store.model().members(group, "").empty(); + } ); - // clang-format on } - Nui::ElementRenderer renderHeader() + Nui::ElementRenderer renderPlaceholder() { using namespace Nui::Elements; using namespace Nui::Attributes; using Nui::Elements::div; - using Nui::Elements::span; + using FileTracking::OutlinerGroup; // clang-format off - return header{ - class_ = "file-tracking-header", + return div{ + class_ = "file-tracking-panel-placeholder", + "data-ft-detail"_attr = "none", }( - div{class_ = "file-tracking-header-stats"}( - span{class_ = "file-tracking-stat"}( - span{class_ = "file-tracking-stat-label"}( - Nui::Elements::text{std::string{language->get("fileTracking", "headerActive")}}() - ), - span{class_ = "file-tracking-stat-value"}( - Nui::observe(entryCount).generate([this]() -> Nui::ElementRenderer - { - return Nui::Elements::text{fmt::format("{}", entryCount.value())}(); - }) - ) - ), - span{class_ = "file-tracking-stat"}( - span{class_ = "file-tracking-stat-label"}( - Nui::Elements::text{std::string{language->get("fileTracking", "headerOrphaned")}}() - ), - span{class_ = "file-tracking-stat-value file-tracking-stat-orphaned"}( - Nui::observe(orphanedCount).generate([this]() -> Nui::ElementRenderer - { - if (orphanedCount.value() < 0) - return Nui::Elements::text{"-"}(); - return Nui::Elements::text{fmt::format("{}", orphanedCount.value())}(); - }) - ) - ) - ), - renderProgress(), - div{class_ = "file-tracking-header-actions"}( - ScriptNuiComponents::button({ - .text = Nui::observe(cleanupRunning).generate([](bool running) -> std::string - { - return std::string{language->get("fileTracking", running ? "headerCleanupRunning" : "headerCleanupButton")}; - }), - .attributes = - { - Nui::Attributes::onClick = lifetime.guarded( - [this](Nui::val) - { - if (!cleanupRunning.value()) - triggerCleanup(); - } - ), - disabled = Nui::observe(cleanupRunning).generate([](bool running) { return running; }), - }, - .styleVariant = ScriptNuiComponents::StyleVariant::Warning, - }) - ) + Nui::observe( + *store.countOf(OutlinerGroup::NeedsYou), + *store.countOf(OutlinerGroup::Syncing), + *store.countOf(OutlinerGroup::Watching), + *store.countOf(OutlinerGroup::Stopped), + *store.countOf(OutlinerGroup::Orphaned), + store.filter() + ).generate([this]() -> Nui::ElementRenderer { + return Nui::Elements::text{ + language->get("fileTracking", nothingTracked() ? "nothingTracked" : "nothingSelected") + }(); + }) ); // clang-format on } /** - * @brief A cell with a switch that shows @p shown and forwards its changes as @p option of @p watchId. - * @param downloadedCopy Whether the row is a temporary copy, which never moves or deletes the server's file: the - * switch of such an option is disabled then. Nothing for an option that applies to every row. + * @brief The detail of what the outliner selects, the placeholder while that is nothing or not known. */ - ScriptNuiComponents::ResizableTable::TableCell switchCell( - Ids::WatchId const& watchId, - std::shared_ptr> shown, - SharedData::WatchOption option, - std::shared_ptr> downloadedCopy = nullptr - ) + Nui::ElementRenderer renderDetail() { - using namespace ScriptNuiComponents; - return ResizableTable::TableCell{ - [this, watchId, shown = std::move(shown), option, downloadedCopy = std::move(downloadedCopy)]( - std::unique_ptr - ) -> Nui::ElementRenderer - { - std::vector attributes{}; - if (downloadedCopy) - { - attributes.push_back(Nui::Attributes::disabled = Nui::observe(*downloadedCopy).generate( - [](bool isCopy) - { - return isCopy; - } - )); - attributes.push_back(Nui::Attributes::title = Nui::observe(*downloadedCopy).generate( - [](bool isCopy) - { - return isCopy ? std::string{language->get( - "fileTracking", "downloadedCopyOptionHint" - )} - : std::string{}; - } - )); - } - return ScriptNuiComponents::switch_({ - .isChecked = shown, - .attributes = std::move(attributes), - .onChange = lifetime.guarded( - [this, watchId, option](bool checked, Nui::WebApi::MouseEvent const&) - { - forward("setOption", watchId.value(), Utility::enumToString(option), checked); - } - ), - }); - }, - }; - } - - /** - * @brief A cell that shows @p text and follows its changes. - */ - static ScriptNuiComponents::ResizableTable::TableCell textCell(std::shared_ptr> text) - { - using namespace ScriptNuiComponents; - return ResizableTable::TableCell{ - [text = std::move(text)](std::unique_ptr) -> Nui::ElementRenderer - { - return Nui::Elements::div{ - Nui::Attributes::style = "display:contents", - }(Nui::observe(*text).generate( - [](std::string const& value) -> Nui::ElementRenderer - { - return Nui::Elements::text{value}(); - } - )); - }, - }; - } - - ScriptNuiComponents::ResizableTable::TableRow buildRow(Ids::WatchId const& watchId, Row const& row) - { - using namespace ScriptNuiComponents; - return ResizableTable::TableRow{ - textCell(row.name), - textCell(row.remotePath), - switchCell(watchId, row.autoReupload, SharedData::WatchOption::AutoReupload), - switchCell(watchId, row.moveRemote, SharedData::WatchOption::MoveRemoteOnLocalMove, row.downloadedCopy), - switchCell( - watchId, row.deleteRemote, SharedData::WatchOption::DeleteRemoteOnLocalDelete, row.downloadedCopy - ), - switchCell(watchId, row.checkRemote, SharedData::WatchOption::CheckRemoteChanges), - ResizableTable::TableCell{ - [uploading = row.uploading](std::unique_ptr) -> Nui::ElementRenderer - { - return Nui::Elements::div{ - Nui::Attributes::class_ = "file-tracking-status", - Nui::Attributes::style = "display:contents", - }(Nui::observe(*uploading) - .generate( - [](bool isUploading) -> Nui::ElementRenderer - { - return Nui::Elements::text{std::string{language->get( - "fileTracking", isUploading ? "statusUploading" : "statusWatching" - )}}(); - } - )); - }, - }, - ResizableTable::TableCell{ - [this, watchId](std::unique_ptr) -> Nui::ElementRenderer - { - return ScriptNuiComponents::button({ - .text = std::string{language->get("fileTracking", "stopButton")}, - .attributes = - { - Nui::Attributes::onClick = lifetime.guarded( - [this, watchId](Nui::val) - { - forward("stop", watchId.value()); - } - ), - }, - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - }); - }, - }, - }; + auto const& selection = store.selection().value(); + if (auto const* const watchId = std::get_if(&selection); watchId != nullptr) + { + if (const auto item = store.watchItem(*watchId); item) + return FileTracking::watchDetail(store, actions, item); + } + if (auto const* const instanceId = std::get_if(&selection); instanceId != nullptr) + { + if (const auto item = store.orphanItem(*instanceId); item) + return FileTracking::orphanDetail(store, actions, item); + } + return renderPlaceholder(); } }; -FileTrackingPanel::FileTrackingPanel(ConfirmDialog* confirmDialog) - : impl_(std::make_unique(confirmDialog)) +FileTrackingPanel::FileTrackingPanel(Params params) + : impl_(std::make_unique(std::move(params))) {} ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL(FileTrackingPanel); void FileTrackingPanel::activate(Ids::SessionId sessionId) { - deactivate(); - impl_->sessionId = std::move(sessionId); - impl_->listen(); - impl_->refreshOrphanedCount(); + impl_->client.activate(std::move(sessionId)); + impl_->attention.reset(); } void FileTrackingPanel::deactivate() { - impl_->listeners.clear(); - impl_->table->clear(); - impl_->rowOrder.clear(); - impl_->rows.clear(); - impl_->entryCount = std::size_t{0}; - impl_->sessionId = {}; - impl_->orphanedCount = -1; - impl_->cleanupRunning = false; - impl_->progress = SharedData::TrackerProgress{}; + impl_->client.deactivate(); + impl_->attention.reset(); } void FileTrackingPanel::open( @@ -631,27 +409,7 @@ void FileTrackingPanel::open( std::function onRefused ) { - Nui::RpcClient::callWithBackChannel( - impl_->rpcName("open"), - impl_->lifetime.guarded( - [remotePath, onOpened = std::move(onOpened), onRefused = std::move(onRefused)](Nui::val response) - { - if (!Implementation::succeeded(response)) - { - if (const auto error = Implementation::errorOf(response)) - return onRefused(*error); - return Log::warn("FileTracking: '{}' was not downloaded, it is not opened", remotePath.value()); - } - const auto localPath = Paths::LocalPath::fromUtf8(response["localPath"].as()); - if (!localPath) - return onRefused(localPath.error().toString()); - onOpened(Ids::makeInstanceId(response["instanceId"].as()), *localPath); - } - ), - remotePath.value(), - static_cast(sizeBytes), - watch - ); + impl_->client.open(remotePath, sizeBytes, watch, std::move(onOpened), std::move(onRefused)); } void FileTrackingPanel::checkUploadAndWatch( @@ -661,22 +419,7 @@ void FileTrackingPanel::checkUploadAndWatch( std::function onRefused ) { - Nui::RpcClient::callWithBackChannel( - impl_->rpcName("checkUploadAndWatch"), - impl_->lifetime.guarded( - [onWatchable = std::move(onWatchable), onRefused = std::move(onRefused)](Nui::val response) - { - if (!Implementation::succeeded(response)) - return onRefused(Implementation::errorOf(response).value_or("")); - const auto canonicalItem = Paths::LocalPath::fromUtf8(response["item"].as()); - if (!canonicalItem) - return onRefused(canonicalItem.error().toString()); - onWatchable(*canonicalItem); - } - ), - item.toUtf8(), - directory.value() - ); + impl_->client.checkUploadAndWatch(item, directory, std::move(onWatchable), std::move(onRefused)); } void FileTrackingPanel::uploadAndWatch( @@ -685,23 +428,25 @@ void FileTrackingPanel::uploadAndWatch( std::function onRefused ) { - Nui::RpcClient::callWithBackChannel( - impl_->rpcName("uploadAndWatch"), - impl_->lifetime.guarded( - [onRefused = std::move(onRefused)](Nui::val response) - { - if (!Implementation::succeeded(response)) - onRefused(Implementation::errorOf(response).value_or("")); - } - ), - item.toUtf8(), - remotePath.value() - ); + impl_->client.uploadAndWatch(item, remotePath, std::move(onRefused)); } void FileTrackingPanel::discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) { - impl_->forward("discard", instanceId.value(), localPath.toUtf8()); + impl_->client.discard(instanceId, localPath); +} + +void FileTrackingPanel::setRevealOnServer(std::function revealOnServer) +{ + impl_->revealOnServer = std::move(revealOnServer); +} + +void FileTrackingPanel::onTabActivated() +{ + if (!impl_->attention.unseen()) + return; + impl_->store.focusNewestConflict(); + impl_->attention.acknowledge(); } Nui::ElementRenderer FileTrackingPanel::operator()() @@ -710,13 +455,28 @@ Nui::ElementRenderer FileTrackingPanel::operator()() using namespace Nui::Attributes; using Nui::Elements::div; + auto* const implementation = impl_.get(); + // clang-format off return div{ class_ = "file-tracking-panel", - style = "width: 100%; height: 100%;", }( - impl_->renderHeader(), - (*impl_->table)({}) + div{ + class_ = "file-tracking-panel-layout", + }( + div{ + class_ = "file-tracking-panel-outliner", + }( + FileTracking::outliner(implementation->store, implementation->actions) + ), + div{ + class_ = "file-tracking-panel-detail", + }( + Nui::observe(implementation->store.selection()).generate([implementation]() -> Nui::ElementRenderer { + return implementation->renderDetail(); + }) + ) + ) ); // clang-format on } diff --git a/frontend/test/frontend/main.cpp b/frontend/test/frontend/main.cpp index 72204b0f..a0526819 100644 --- a/frontend/test/frontend/main.cpp +++ b/frontend/test/frontend/main.cpp @@ -5,7 +5,6 @@ #include "test_item_order.hpp" #include "test_numbered_saves.hpp" #include "test_overwrite_decisions.hpp" -#include "test_row_order.hpp" #include "test_selection_model.hpp" #include "test_side_paths.hpp" #include "test_tracking_model.hpp" diff --git a/frontend/test/frontend/test_row_order.hpp b/frontend/test/frontend/test_row_order.hpp deleted file mode 100644 index 2175aa86..00000000 --- a/frontend/test/frontend/test_row_order.hpp +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include - -#include - -#include - -namespace FileTracking::Test -{ - TEST(RowOrderTests, GivesTheIndexOfARowWhenItIsRemoved) - { - RowOrder rows; - rows.add("first"); - rows.add("second"); - rows.add("third"); - - EXPECT_EQ(rows.remove("second"), std::size_t{1}); - EXPECT_EQ(rows.remove("third"), std::size_t{1}); - EXPECT_EQ(rows.remove("first"), std::size_t{0}); - EXPECT_EQ(rows.size(), std::size_t{0}); - } - - TEST(RowOrderTests, KnowsNoRowOfAnUnknownOrRemovedKey) - { - RowOrder rows; - rows.add("only"); - - EXPECT_FALSE(rows.remove("other").has_value()); - ASSERT_TRUE(rows.remove("only").has_value()); - EXPECT_FALSE(rows.remove("only").has_value()); - } -} From 76906623f442d5ddd73d86f9fbab7cff79c3bd76 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:31:23 +0200 Subject: [PATCH 040/114] Revealed file tracking items on the server in the remote file explorer side --- frontend/source/frontend/session.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/source/frontend/session.cpp b/frontend/source/frontend/session.cpp index 60ad2f0e..b650644f 100644 --- a/frontend/source/frontend/session.cpp +++ b/frontend/source/frontend/session.cpp @@ -210,6 +210,16 @@ struct Session::Implementation ); fileExplorerPanel.dropLayoutMetadata(sessionLayoutId); + + fileTrackingPanel.setRevealOnServer( + [this](Paths::RemotePath const& path) + { + auto* const side = fileExplorerPanel.remoteFileGridSide(); + if (side == nullptr) + return; + side->path(path.parent().value_or(path).value()); + } + ); } /** From 5e30aacc2148bab8276a15f0e55e27dab62732fd Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:31:23 +0200 Subject: [PATCH 041/114] Passed the file tracking tab activation to the content panel manager --- .../session_components/session_layout_initializer.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/source/frontend/session_components/session_layout_initializer.cpp b/frontend/source/frontend/session_components/session_layout_initializer.cpp index 0086f377..c3befc34 100644 --- a/frontend/source/frontend/session_components/session_layout_initializer.cpp +++ b/frontend/source/frontend/session_components/session_layout_initializer.cpp @@ -766,6 +766,17 @@ void SessionLayoutInitializer::initialize() std::placeholders::_1 ) ); + addPanelArgument.set( + "fileTrackingActivated", + Nui::bind( + [this]() -> Nui::val + { + impl_->fileTrackingPanel->onTabActivated(); + Nui::globalEventContext.executeActiveEventsImmediately(); + return Nui::val::undefined(); + } + ) + ); Log::info("Adding panel to content panel manager with layout id '{}'", impl_->sessionLayoutId); const auto addPanelResult = Nui::val::global("contentPanelManager").call("addPanel", addPanelArgument); From a64d157a739e8a295537aeb7a3844f66229a975e Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:33:05 +0200 Subject: [PATCH 042/114] Laid out the file tracking panel side by side, stacked below 520 px --- static/styles/file_tracking_panel.css | 156 +++++++++----------------- 1 file changed, 50 insertions(+), 106 deletions(-) diff --git a/static/styles/file_tracking_panel.css b/static/styles/file_tracking_panel.css index 61d0d57e..a5e5dd75 100644 --- a/static/styles/file_tracking_panel.css +++ b/static/styles/file_tracking_panel.css @@ -1,132 +1,76 @@ .file-tracking-panel { + container: file-tracking-panel / inline-size; width: 100%; height: 100%; - display: flex; - flex-direction: column; - overflow: auto; - padding: 0; - box-sizing: border-box; + overflow: hidden; + background: var(--background-color); + color: var(--color); + font-size: 0.8125rem; + line-height: 1.35; } -header.file-tracking-header { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 8px; - background: linear-gradient(to bottom, - color-mix(in srgb, var(--background-color-fields) 6%, var(--darker-background)), - var(--darker-background)); - border-bottom: 1px solid color-mix(in srgb, var(--theme-color) 35%, var(--border-color, #3a3a3a) 65%); - box-shadow: 0 3px 12px rgba(0, 0, 0, 0.38); - flex-shrink: 0; - position: relative; - z-index: 1; +.file-tracking-panel-layout { + display: grid; + grid-template-columns: clamp(180px, 34%, 260px) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + width: 100%; + height: 100%; } -.file-tracking-header-stats { +.file-tracking-panel-outliner { display: flex; - gap: 5px; - flex: 1; + flex-direction: column; min-width: 0; -} - -.file-tracking-stat { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 6px 10px; - align-self: stretch; - border-radius: 4px; - border: 1px solid var(--script-nui-components-button-border-color-regular); - font-size: 0.82em; - background: var(--script-nui-components-button-color-regular); -} - -.file-tracking-stat-label { - color: var(--color-secondary, rgba(255, 255, 255, 0.55)); - user-select: none; -} - -.file-tracking-stat-value { - font-weight: 600; - font-variant-numeric: tabular-nums; - min-width: 1.5ch; - text-align: center; -} + min-height: 0; + overflow: auto; + background: var(--darker-background); + border-right: 1px solid color-mix(in srgb, var(--theme-color) 35%, var(--border-color-fields) 65%); -.file-tracking-stat-orphaned { - color: var(--warning-color, #f5a623); + &>* { + flex: 1 1 auto; + min-height: 0; + } } -.file-tracking-progress { - display: inline-flex; - align-items: center; - gap: 8px; +.file-tracking-panel-detail { + display: flex; + flex-direction: column; min-width: 0; - font-size: 0.82em; -} - -.file-tracking-progress-idle { - display: none; -} - -.file-tracking-progress-track { - position: relative; - width: 140px; - height: 6px; - flex-shrink: 0; - border-radius: 3px; - overflow: hidden; - background: color-mix(in srgb, var(--theme-color, #4caf50) 20%, transparent); -} - -.file-tracking-progress-fill { - height: 100%; - background: var(--theme-color, #4caf50); - transition: width 0.2s ease-out; -} - -.file-tracking-progress-track::after { - content: ""; - position: absolute; - inset: 0; - width: 30%; - background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.35), transparent); - animation: file-tracking-progress-sweep 1.2s linear infinite; -} - -@keyframes file-tracking-progress-sweep { - from { - transform: translateX(-100%); - } + min-height: 0; + overflow: auto; - to { - transform: translateX(340%); + &>* { + flex: 1 0 auto; + min-width: 0; } } -.file-tracking-progress-text { - white-space: nowrap; - font-variant-numeric: tabular-nums; -} +.file-tracking-panel [data-ft-detail="watch"] { + display: flex; + flex-direction: column; -.file-tracking-progress-waiting { - white-space: nowrap; - color: var(--warning-color, #f5a623); + &>:last-child { + flex: 1 0 auto; + } } -.file-tracking-header-actions { +.file-tracking-panel-placeholder { display: flex; - gap: 6px; align-items: center; - flex-shrink: 0; + justify-content: center; + padding: 16px; + color: var(--color-secondary); + text-align: center; } -.file-tracking-panel .script-nui-resizable-table { - flex: 1; - min-height: 0; +@container file-tracking-panel (max-width: 520px) { + .file-tracking-panel-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: fit-content(40%) minmax(0, 1fr); + } - & th { - padding: 3px; + .file-tracking-panel-outliner { + border-right: none; + border-bottom: 1px solid color-mix(in srgb, var(--theme-color) 35%, var(--border-color-fields) 65%); } -} \ No newline at end of file +} From f66126dc5df21cc524cb8f2eade8caf4582af569 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:33:45 +0200 Subject: [PATCH 043/114] Showed the client's translated error when deleting an orphaned copy failed --- frontend/source/frontend/session_components/file_tracking.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index 82c9891b..064f1b38 100644 --- a/frontend/source/frontend/session_components/file_tracking.cpp +++ b/frontend/source/frontend/session_components/file_tracking.cpp @@ -165,9 +165,7 @@ struct FileTrackingPanel::Implementation [name](std::string const& error) { Notifications::error(fmt::format( - fmt::runtime(language->get("fileTracking", "deleteOrphanFailed")), - name, - language->translate(error) + fmt::runtime(language->get("fileTracking", "deleteOrphanFailed")), name, error )); } ) From 103c8c0570487cc8c87a023a6aa6218b3fb1817b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:34:32 +0200 Subject: [PATCH 044/114] Observed the shared group counts in the file tracking placeholder --- .../frontend/session_components/file_tracking.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index 064f1b38..7efcb885 100644 --- a/frontend/source/frontend/session_components/file_tracking.cpp +++ b/frontend/source/frontend/session_components/file_tracking.cpp @@ -346,11 +346,11 @@ struct FileTrackingPanel::Implementation "data-ft-detail"_attr = "none", }( Nui::observe( - *store.countOf(OutlinerGroup::NeedsYou), - *store.countOf(OutlinerGroup::Syncing), - *store.countOf(OutlinerGroup::Watching), - *store.countOf(OutlinerGroup::Stopped), - *store.countOf(OutlinerGroup::Orphaned), + store.countOf(OutlinerGroup::NeedsYou), + store.countOf(OutlinerGroup::Syncing), + store.countOf(OutlinerGroup::Watching), + store.countOf(OutlinerGroup::Stopped), + store.countOf(OutlinerGroup::Orphaned), store.filter() ).generate([this]() -> Nui::ElementRenderer { return Nui::Elements::text{ From a675ae5ed48ad3b78b5c0cb0a4f743ee8e74f0cc Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:35:57 +0200 Subject: [PATCH 045/114] Kept the dock tab attention alive until the store and client are gone --- frontend/source/frontend/session_components/file_tracking.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index 7efcb885..62645764 100644 --- a/frontend/source/frontend/session_components/file_tracking.cpp +++ b/frontend/source/frontend/session_components/file_tracking.cpp @@ -38,6 +38,7 @@ struct FileTrackingPanel::Implementation { explicit Implementation(Params params) : confirmDialog{params.confirmDialog} + , attention{std::move(params.sessionLayoutId)} , store{FileTracking::translatedTexts()} , client{ store, @@ -46,7 +47,6 @@ struct FileTrackingPanel::Implementation notifyEnded(ended); } } - , attention{std::move(params.sessionLayoutId)} { store.onAttentionChanged( [this](FileTracking::AttentionCounts const& counts) @@ -58,9 +58,9 @@ struct FileTrackingPanel::Implementation } ConfirmDialog* confirmDialog; + FileTracking::DockTabAttention attention; FileTracking::TrackingStore store; FileTracking::TrackerClient client; - FileTracking::DockTabAttention attention; FileTracking::TrackingActions actions{}; std::function revealOnServer{}; Utility::LifetimeToken lifetime{}; From 3305da5d33f9c49e35b58c569f2bab51657551aa Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:36:07 +0200 Subject: [PATCH 046/114] Added a Lumino tab renderer that shows count badges from the title dataset --- .../content_panels/badge_tab_renderer.ts | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/static/source/content_panels/badge_tab_renderer.ts b/static/source/content_panels/badge_tab_renderer.ts index cb0ff5c3..bfebb1e0 100644 --- a/static/source/content_panels/badge_tab_renderer.ts +++ b/static/source/content_panels/badge_tab_renderer.ts @@ -1 +1,127 @@ -export {}; +import { + DockPanel, + TabBar, + Title, + Widget +} from '@lumino/widgets'; +import { + ElementARIAAttrs, + ElementBaseAttrs, + VirtualElement, + h +} from '@lumino/virtualdom'; + +/** Phase of a running tab glow; switching between the two restarts the keyframes. */ +type AttentionPulse = 'a' | 'b'; + +/** + * Keys of Title.dataset that BadgeTabRenderer reads. Lumino also puts the whole dataset onto the tab as data-* + * attributes, so the tab carries data-badge-warning, data-badge-danger and data-attention-pulse too. + */ +const tabBadgeKeys = { + warning: 'badge-warning', + warningTitle: 'badge-warning-title', + danger: 'badge-danger', + dangerTitle: 'badge-danger-title', + pulse: 'attention-pulse', +} as const; + +interface TabBadge { + variant: 'warning' | 'danger'; + countKey: string; + titleKey: string; + popsWithPulse: boolean; +} + +const tabBadges: readonly TabBadge[] = [ + { variant: 'warning', countKey: tabBadgeKeys.warning, titleKey: tabBadgeKeys.warningTitle, popsWithPulse: true }, + { variant: 'danger', countKey: tabBadgeKeys.danger, titleKey: tabBadgeKeys.dangerTitle, popsWithPulse: false }, +]; + +function badgeCount(dataset: Title.Dataset, badge: TabBadge): number { + const count = Number(dataset[badge.countKey] ?? 0); + return Number.isFinite(count) && count > 0 ? count : 0; +} + +function shownBadgeTitles(dataset: Title.Dataset): string[] { + return tabBadges + .filter(badge => badgeCount(dataset, badge) > 0) + .map(badge => dataset[badge.titleKey] ?? '') + .filter(title => title !== ''); +} + +/** One badge in the 5cript-nui-components Badge markup, hidden at 0 and popping with the tab's pulse. */ +function renderBadge(dataset: Title.Dataset, badge: TabBadge): VirtualElement { + const count = badgeCount(dataset, badge); + const pulse = dataset[tabBadgeKeys.pulse]; + const badgeDataset: Record = { variant: badge.variant }; + if (count === 0) + badgeDataset.hidden = ''; + if (badge.popsWithPulse && (pulse === 'a' || pulse === 'b')) + badgeDataset.pop = pulse; + const attributes = { className: 'script-nui-badge', dataset: badgeDataset }; + const title = dataset[badge.titleKey] ?? ''; + return h.span(title === '' ? attributes : { ...attributes, title }, String(count)); +} + +/** + * Tab renderer that shows count badges between the label and the close icon. The counts come from the title's + * dataset (tabBadgeKeys); a tab without a positive count renders exactly like the default renderer's. + */ +class BadgeTabRenderer extends TabBar.Renderer { + renderTab(data: TabBar.IRenderData): VirtualElement { + const key = this.createTabKey(data); + return h.li( + { + id: key, + key, + className: this.createTabClass(data), + title: data.title.caption, + style: this.createTabStyle(data), + dataset: this.createTabDataset(data), + ...this.createTabARIA(data), + }, + this.renderIcon(data), + this.renderLabel(data), + this.renderBadges(data), + data.title.closable ? this.renderCloseIcon(data) : null + ); + } + + /** The badge container, or null while no count is above 0. */ + renderBadges(data: TabBar.IRenderData): VirtualElement | null { + const dataset = data.title.dataset; + if (tabBadges.every(badge => badgeCount(dataset, badge) === 0)) + return null; + return h.div( + { className: 'lm-TabBar-tabBadges', 'aria-hidden': 'true' }, + tabBadges.map(badge => renderBadge(dataset, badge)) + ); + } + + /** Names the tab with its label and the shown badge titles, as the badges themselves are aria-hidden. */ + createTabARIA(data: TabBar.IRenderData): ElementARIAAttrs | ElementBaseAttrs { + const aria = super.createTabARIA(data); + const titles = shownBadgeTitles(data.title.dataset); + if (titles.length === 0) + return aria; + return { ...aria, 'aria-label': [data.title.label, ...titles].join(', ') }; + } +} + +/** Dock renderer whose tab bars use BadgeTabRenderer. */ +class BadgeDockRenderer extends DockPanel.Renderer { + readonly tabRenderer = new BadgeTabRenderer(); + + createTabBar(document?: Document | ShadowRoot): TabBar { + const tabBar = new TabBar({ document, renderer: this.tabRenderer }); + tabBar.addClass('lm-DockPanel-tabBar'); + return tabBar; + } +} + +/** Shared like Lumino's default renderers, so tab keys stay stable when a tab moves between tab bars. */ +const badgeDockRenderer = new BadgeDockRenderer(); + +export { BadgeDockRenderer, BadgeTabRenderer, badgeDockRenderer, tabBadgeKeys }; +export type { AttentionPulse }; From fc8767f96b9ed002c62f3d469920b1919639754c Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:36:07 +0200 Subject: [PATCH 047/114] Styled the dock tab badges and the amber attention glow --- static/styles/dock_tab_attention.css | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/static/styles/dock_tab_attention.css b/static/styles/dock_tab_attention.css index e69de29b..0a8879c7 100644 --- a/static/styles/dock_tab_attention.css +++ b/static/styles/dock_tab_attention.css @@ -0,0 +1,60 @@ +/* Badges and glow of a Lumino dock tab, rendered by BadgeTabRenderer from the title's dataset. */ + +.lm-TabBar-tabBadges { + display: inline-flex; + flex: 0 0.01 auto; + align-self: center; + align-items: center; + gap: 4px; + min-width: 0; + margin: -4px -4px -4px 2px; + padding: 4px; + overflow: hidden; +} + +.lm-TabBar-tab[data-attention-pulse] { + isolation: isolate; +} + +.lm-TabBar-tab[data-attention-pulse]::before { + --dock-tab-attention-color: var(--script-nui-components-toast-warning, #e0a02e); + + content: ''; + position: absolute; + inset: -1px -1px 0; + z-index: -1; + border: 1px solid var(--dock-tab-attention-color); + border-bottom: none; + border-radius: inherit; + background-color: color-mix(in srgb, var(--dock-tab-attention-color) 38%, var(--brightness-mixin, black) 62%); + box-shadow: inset 0 0 10px 1px color-mix(in srgb, var(--dock-tab-attention-color) 60%, transparent); + opacity: 0; + pointer-events: none; +} + +.lm-TabBar-tab[data-attention-pulse="a"]::before { + animation: dock-tab-attention-glow-a 0.83s ease-in-out 3; +} + +.lm-TabBar-tab[data-attention-pulse="b"]::before { + animation: dock-tab-attention-glow-b 0.83s ease-in-out 3; +} + +@keyframes dock-tab-attention-glow-a { + 40% { + opacity: 1; + } +} + +@keyframes dock-tab-attention-glow-b { + 40% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .lm-TabBar-tab[data-attention-pulse]::before { + animation: none; + opacity: 0.6; + } +} From 588b7406ee187a51126d538d3742ba1d46992f23 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:36:07 +0200 Subject: [PATCH 048/114] Showed the File Tracking attention on its dock tab and reported tab activation --- static/source/content_panel.ts | 37 ++++++++- static/source/content_panel_manager.ts | 28 +++++++ static/source/content_panels/file_tracking.ts | 77 ++++++++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/static/source/content_panel.ts b/static/source/content_panel.ts index 80396c99..422f2375 100644 --- a/static/source/content_panel.ts +++ b/static/source/content_panel.ts @@ -3,10 +3,11 @@ import { LocalShellTerminal } from './content_panels/local_shell_terminal'; import { FileExplorer } from './content_panels/file_explorer'; import { OperationQueue } from './content_panels/operation_queue'; import { SessionOptions } from './content_panels/session_options'; -import { FileTracking } from './content_panels/file_tracking'; +import { FileTracking, FileTrackingAttention } from './content_panels/file_tracking'; import { CommandHistory } from './content_panels/command_history'; import { CommandSnippets } from './content_panels/command_snippets'; import { NuiWidget } from './content_panels/nui_widget'; +import { badgeDockRenderer } from './content_panels/badge_tab_renderer'; import { BoxPanel, DockPanel, @@ -36,6 +37,8 @@ export interface PanelFactories { sessionOptionsDelete: () => any; fileTrackingFactory: () => HTMLElement | undefined; fileTrackingDelete: () => any; + /** Called when the user activates the File Tracking tab; optional. */ + fileTrackingActivated?: () => any; commandHistoryFactory: () => HTMLElement | undefined; commandHistoryDelete: () => any; commandSnippetsFactory: () => HTMLElement | undefined; @@ -63,6 +66,8 @@ export class ContentPanel { /// observer pins `this.main` alive and keeps stale Nui::bind references /// to the owning Session around after close. private resizeObserver: ResizeObserver | undefined; + /** The last attention of the File Tracking tab, for File Tracking widgets created later. */ + private fileTrackingAttention: FileTrackingAttention | undefined; constructor( id: string, @@ -136,7 +141,7 @@ export class ContentPanel { case 'session-options': return new SessionOptions('SessionOptions', this.factories.sessionOptionsFactory, this.factories.sessionOptionsDelete); case 'file-tracking': - return new FileTracking('File Tracking', this.factories.fileTrackingFactory, this.factories.fileTrackingDelete); + return this.makeFileTracking(); case 'command-history': return new CommandHistory('Command History', this.factories.commandHistoryFactory, this.factories.commandHistoryDelete); case 'command-snippets': @@ -161,6 +166,18 @@ export class ContentPanel { return false; } + /** + * Shows the counts on the File Tracking tab and plays its glow when attention.pulse is set. Widgets created later + * get the counts without the glow. + */ + setFileTrackingAttention(attention: FileTrackingAttention) { + this.fileTrackingAttention = attention; + for (const widget of this.dock.widgets()) { + if (widget instanceof FileTracking) + widget.applyAttention(attention); + } + } + /** Re-applies the translated titles to every tab that has no title of its own. */ relabel() { for (const widget of this.dock.widgets()) { @@ -257,6 +274,7 @@ export class ContentPanel { const dock = new DockPanel({ addButtonEnabled: true, + renderer: badgeDockRenderer, }); dock.addWidget(term); dock.addWidget(explorer, { mode: 'split-right', ref: term }); @@ -264,7 +282,7 @@ export class ContentPanel { dock.addWidget(snippets, { mode: 'tab-after', ref: history }); if (this.engineType === 'ssh') { const queue = new OperationQueue('OperationQueue', f.operationQueueFactory, f.operationQueueDelete); - const fileTracking = new FileTracking('File Tracking', f.fileTrackingFactory, f.fileTrackingDelete); + const fileTracking = this.makeFileTracking(); dock.addWidget(queue, { mode: 'split-bottom', ref: term }); dock.addWidget(fileTracking, { mode: 'tab-after', ref: queue }); } @@ -320,6 +338,7 @@ export class ContentPanel { const dock = new DockPanel({ addButtonEnabled: true, + renderer: badgeDockRenderer, }); const area = { main: deserializeArea(dehydrated.main) }; if (area) { @@ -329,6 +348,18 @@ export class ContentPanel { return dock; } + private makeFileTracking(): FileTracking { + const fileTracking = new FileTracking( + 'File Tracking', + this.factories.fileTrackingFactory, + this.factories.fileTrackingDelete, + this.factories.fileTrackingActivated + ); + if (this.fileTrackingAttention !== undefined) + fileTracking.applyAttention({ ...this.fileTrackingAttention, pulse: false }); + return fileTracking; + } + private applyDefaultSplit(dock: DockPanel) { const saved = dock.saveLayout(); const main = saved.main as DockLayout.ISplitAreaConfig; diff --git a/static/source/content_panel_manager.ts b/static/source/content_panel_manager.ts index 887ace05..810ed1d9 100644 --- a/static/source/content_panel_manager.ts +++ b/static/source/content_panel_manager.ts @@ -6,6 +6,7 @@ import { } from '@lumino/widgets'; import { ChannelId } from './ids.ts'; import { PanelTitleKey, panelTitles } from './content_panels/panel_titles'; +import { FileTrackingAttention } from './content_panels/file_tracking'; interface addPanelArguments { host: HTMLElement; @@ -24,6 +25,8 @@ interface addPanelArguments { sessionOptionsDelete: () => any; fileTrackingFactory: () => HTMLElement; fileTrackingDelete: () => any; + /** Called when the user activates the File Tracking tab; optional. */ + fileTrackingActivated?: () => any; commandHistoryFactory: () => HTMLElement; commandHistoryDelete: () => any; commandSnippetsFactory: () => HTMLElement; @@ -45,6 +48,8 @@ interface addPanelArguments { class ContentPanelManager { private panels: Map = new Map(); private lastAddRequest: { panelId: string; widget: TabBar } | undefined; + /** File Tracking attention sent before its panel was added, applied by addPanel. */ + private pendingFileTrackingAttention: Map = new Map(); addPanel(args: addPanelArguments): boolean { if (!args) { @@ -85,6 +90,7 @@ class ContentPanelManager { sessionOptionsDelete: args.sessionOptionsDelete, fileTrackingFactory: args.fileTrackingFactory, fileTrackingDelete: args.fileTrackingDelete, + fileTrackingActivated: args.fileTrackingActivated, commandHistoryFactory: args.commandHistoryFactory, commandHistoryDelete: args.commandHistoryDelete, commandSnippetsFactory: args.commandSnippetsFactory, @@ -130,10 +136,16 @@ class ContentPanelManager { } this.panels.set(panelId, panel); + const pendingAttention = this.pendingFileTrackingAttention.get(panelId); + if (pendingAttention !== undefined) { + this.pendingFileTrackingAttention.delete(panelId); + panel.setFileTrackingAttention({ ...pendingAttention, pulse: false }); + } return true; } removePanel = (id: string) => { + this.pendingFileTrackingAttention.delete(id); const panel = this.panels.get(id); if (!panel) return; try { @@ -198,6 +210,22 @@ class ContentPanelManager { panel.relabel(); } + /** + * Shows the File Tracking counts on a panel's dock tab and plays the glow when attention.pulse is set. Called by + * DockTabAttention. Attention sent before the panel exists is kept for addPanel, without the glow. + */ + setFileTrackingAttention = (panelId: string, attention: FileTrackingAttention) => { + const panel = this.panels.get(panelId); + if (panel) { + panel.setFileTrackingAttention(attention); + return; + } + if (Number(attention.conflicts) > 0 || Number(attention.failed) > 0) + this.pendingFileTrackingAttention.set(panelId, attention); + else + this.pendingFileTrackingAttention.delete(panelId); + } + renameTerminalById = (panelId: string, channelId: ChannelId, title: string) => { const panel = this.panels.get(panelId); if (!panel) { diff --git a/static/source/content_panels/file_tracking.ts b/static/source/content_panels/file_tracking.ts index 1d33c2d5..d51bed2a 100644 --- a/static/source/content_panels/file_tracking.ts +++ b/static/source/content_panels/file_tracking.ts @@ -1,10 +1,85 @@ import { NuiWidget } from './nui_widget'; +import { AttentionPulse, tabBadgeKeys } from './badge_tab_renderer'; +import { Message } from '@lumino/messaging'; + +/** What the File Tracking dock tab shows, as DockTabAttention sends it through ContentPanelManager. */ +interface FileTrackingAttention { + /** Conflicts waiting in the session, the yellow count. */ + conflicts: number; + /** Failed uploads of the session, the red count. */ + failed: number; + /** Play the glow now. */ + pulse: boolean; + /** Translated title of the yellow count, empty at 0. */ + conflictsText: string; + /** Translated title of the red count, empty at 0. */ + failedText: string; +} + +/** How long a pulse keeps data-attention-pulse on the tab: three glows of 0.83 s and some slack. */ +const attentionPulseMilliseconds = 2600; class FileTracking extends NuiWidget { - constructor(name: string, factory: () => HTMLElement | undefined, deleter: () => any) { + private readonly activated: (() => any) | undefined; + private attention: FileTrackingAttention | undefined = undefined; + /** Alternates on every pulse, so a pulse right after the previous one still restarts the keyframes. */ + private pulsePhase: AttentionPulse = 'b'; + /** Pending while a pulse plays. */ + private pulseTimer: ReturnType | undefined = undefined; + + constructor( + name: string, + factory: () => HTMLElement | undefined, + deleter: () => any, + activated?: () => any + ) { super(name, factory, deleter, 'file-tracking', 'fileTracking'); + this.activated = activated; this.title.closable = true; + this.title.className = 'file-tracking-widget'; + } + + /** Shows the counts on the tab and plays the glow when attention.pulse is set. */ + applyAttention(attention: FileTrackingAttention) { + this.attention = attention; + if (attention.pulse) { + this.pulsePhase = this.pulsePhase === 'a' ? 'b' : 'a'; + clearTimeout(this.pulseTimer); + this.pulseTimer = setTimeout(() => { + this.pulseTimer = undefined; + this.updateTitleDataset(); + }, attentionPulseMilliseconds); + } + this.updateTitleDataset(); + } + + dispose(): void { + clearTimeout(this.pulseTimer); + this.pulseTimer = undefined; + super.dispose(); + } + + protected onActivateRequest(message: Message): void { + super.onActivateRequest(message); + this.activated?.(); + } + + /** Lumino re-renders the tab only when the title changes, so this always assigns a new dataset object. */ + private updateTitleDataset() { + const attention = this.attention; + if (attention === undefined) + return; + const dataset: Record = { + [tabBadgeKeys.warning]: String(attention.conflicts), + [tabBadgeKeys.warningTitle]: attention.conflictsText ?? '', + [tabBadgeKeys.danger]: String(attention.failed), + [tabBadgeKeys.dangerTitle]: attention.failedText ?? '', + }; + if (this.pulseTimer !== undefined) + dataset[tabBadgeKeys.pulse] = this.pulsePhase; + this.title.dataset = dataset; } } export { FileTracking }; +export type { FileTrackingAttention }; From 33e540d64ea5c2aaad0c0b4772aea0d67b4e65b9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:38:08 +0200 Subject: [PATCH 049/114] Kept retried failures aside and told why unreadable local files fail - FailureList::retry takes a failure off until its path uploads or fails again, which brings back its id and place - failureInfoOf(std::error_code) maps a local file that cannot be looked at to its local reason and system message --- .../backend/file_tracking/failure_list.hpp | 62 ++++++++++++++-- .../backend/file_tracking/failure_reason.hpp | 6 ++ .../backend/file_tracking/failure_reason.cpp | 9 +++ backend/test/backend/test_failure_list.hpp | 70 +++++++++++++++++++ backend/test/backend/test_failure_reason.hpp | 21 ++++++ 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/backend/include/backend/file_tracking/failure_list.hpp b/backend/include/backend/file_tracking/failure_list.hpp index b5ecb53e..8b34da79 100644 --- a/backend/include/backend/file_tracking/failure_list.hpp +++ b/backend/include/backend/file_tracking/failure_list.hpp @@ -16,7 +16,8 @@ namespace FileTracking { /** * @brief The failed uploads of the watches, one per watch and local path, in the order their paths first failed. - * They wait for Retry or Dismiss, nothing retries them. Plain bookkeeping, it tells nobody. + * They wait for Retry or Dismiss, nothing retries them. A retried failure leaves the list until its path + * uploads or fails again, see retry(). Plain bookkeeping, it tells nobody. */ class FailureList { @@ -38,9 +39,10 @@ namespace FileTracking stored = failure; return failure; } - failure.failureId = Ids::generateFailureId(); - failure.attempts = 1u; - const auto arrival = nextArrival_++; + const auto retried = takeRetried(failure.watchId, failure.localPath); + failure.failureId = retried ? retried->failure.failureId : Ids::generateFailureId(); + failure.attempts = retried ? retried->failure.attempts + 1u : 1u; + const auto arrival = retried ? retried->arrival : nextArrival_++; index.byPath.emplace(failure.localPath, failure.failureId); index.byArrival.emplace(arrival, failure.failureId); failures_.emplace(failure.failureId, Stored{.failure = failure, .arrival = arrival}); @@ -110,12 +112,30 @@ namespace FileTracking } /** - * @brief Takes the failure of the watch @p watchId at @p localPath off the list, once that path uploaded. + * @brief Takes the failure @p failureId off the list while its path is retried: when it fails again, record() + * lists it again with its id, its place and one attempt more. Once it uploaded, removePath() forgets it. + * @return It, nothing when it was not listed. + */ + std::optional retry(Ids::FailureId const& failureId) + { + auto node = failures_.extract(failureId); + if (node.empty()) + return std::nullopt; + unindex(node.mapped()); + auto const& failure = node.mapped().failure; + retried_[failure.watchId].insert_or_assign(failure.localPath, node.mapped()); + return failure; + } + + /** + * @brief Takes the failure of the watch @p watchId at @p localPath off the list, once that path uploaded, and + * forgets a retried one there. * @return It, nothing when none was listed there. */ std::optional removePath(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) { + takeRetried(watchId, localPath); const auto* failure = find(watchId, localPath); if (!failure) return std::nullopt; @@ -125,7 +145,8 @@ namespace FileTracking /** * @brief Takes the failures of the watch @p watchId at @p directory and below it off the list, once a - * directory upload went through, but for the paths in @p keep: those it left out. + * directory upload went through, but for the paths in @p keep: those it left out. Retried ones there + * are forgotten alike. * @return They, in the order their paths first failed. */ std::vector removeWithin( @@ -134,6 +155,18 @@ namespace FileTracking std::set const& keep = {} ) { + if (const auto retried = retried_.find(watchId); retried != retried_.end()) + { + std::erase_if( + retried->second, + [&directory, &keep](auto const& stored) + { + return stored.first.isWithin(directory) && !keep.contains(stored.first); + } + ); + if (retried->second.empty()) + retried_.erase(retried); + } std::vector within; if (const auto* index = indexOf(watchId)) for (auto const& failureId : index->byArrival | std::views::values) @@ -154,6 +187,7 @@ namespace FileTracking */ std::vector removeWatch(Ids::WatchId const& watchId) { + retried_.erase(watchId); auto node = watches_.extract(watchId); std::vector removed; if (node.empty()) @@ -171,6 +205,7 @@ namespace FileTracking { auto failures = std::exchange(failures_, {}); watches_.clear(); + retried_.clear(); std::map byArrival; for (auto& stored : failures | std::views::values) byArrival.emplace(stored.arrival, std::move(stored.failure)); @@ -203,6 +238,19 @@ namespace FileTracking return found == watches_.end() ? nullptr : &found->second; } + std::optional takeRetried(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) + { + const auto watch = retried_.find(watchId); + if (watch == retried_.end()) + return std::nullopt; + auto node = watch->second.extract(localPath); + if (watch->second.empty()) + retried_.erase(watch); + if (node.empty()) + return std::nullopt; + return std::move(node.mapped()); + } + void unindex(Stored const& stored) { const auto index = watches_.find(stored.failure.watchId); @@ -216,6 +264,8 @@ namespace FileTracking std::map failures_{}; std::map watches_{}; + /** @brief The failures taken off for a retry, see retry(), by watch and local path. */ + std::map> retried_{}; std::uint64_t nextArrival_{0u}; }; } diff --git a/backend/include/backend/file_tracking/failure_reason.hpp b/backend/include/backend/file_tracking/failure_reason.hpp index b29cf755..dcc66c5b 100644 --- a/backend/include/backend/file_tracking/failure_reason.hpp +++ b/backend/include/backend/file_tracking/failure_reason.hpp @@ -5,6 +5,7 @@ #include #include +#include #include namespace FileTracking @@ -20,6 +21,11 @@ namespace FileTracking */ UploadFailureInfo failureInfoOf(SharedData::IndexedFailure const& failure); + /** + * @brief Why the upload of a local file failed that could not be looked at or read, the system said @p error. + */ + UploadFailureInfo failureInfoOf(std::error_code const& error); + /** * @brief The entries below the directory @p directory that its upload, which ended as @p completed, left out. An * entry whose name is no local path is told as the directory itself. diff --git a/backend/source/backend/file_tracking/failure_reason.cpp b/backend/source/backend/file_tracking/failure_reason.cpp index 63e6a169..a50d709e 100644 --- a/backend/source/backend/file_tracking/failure_reason.cpp +++ b/backend/source/backend/file_tracking/failure_reason.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include @@ -160,6 +162,13 @@ namespace FileTracking }; } + UploadFailureInfo failureInfoOf(std::error_code const& error) + { + return { + .reason = localReasonOf(localFileProblemOf(error)), .detail = unlessEmpty(Utility::errorMessage(error)) + }; + } + std::vector failedEntriesOf(SharedData::OperationCompleted const& completed, Paths::LocalPath const& directory) { diff --git a/backend/test/backend/test_failure_list.hpp b/backend/test/backend/test_failure_list.hpp index d190b392..de28f512 100644 --- a/backend/test/backend/test_failure_list.hpp +++ b/backend/test/backend/test_failure_list.hpp @@ -258,6 +258,76 @@ namespace Test::FailureListCases EXPECT_TRUE(failures_.removeAll().empty()); } + TEST_F(FailureListTest, ARetriedFailureLeavesTheListAndComesBackWithItsIdAndPlaceWhenItFailsAgain) + { + const auto first = failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.record(failureAt(watch_, "/watched/b.txt")); + + const auto retried = failures_.retry(first.failureId); + + ASSERT_TRUE(retried.has_value()); + EXPECT_EQ(retried->failureId, first.failureId); + EXPECT_EQ(retried->attempts, 2u); + EXPECT_EQ(failures_.find(first.failureId), nullptr); + EXPECT_EQ(failures_.find(watch_, localPathOf("/watched/a.txt")), nullptr); + EXPECT_EQ(failures_.countOf(watch_), 1u); + EXPECT_FALSE(failures_.retry(first.failureId).has_value()); + EXPECT_FALSE(failures_.remove(first.failureId).has_value()); + + const auto again = + failures_.record(failureAt(watch_, "/watched/a.txt", SharedData::UploadFailureReason::LockedLocally)); + EXPECT_EQ(again.failureId, first.failureId); + EXPECT_EQ(again.attempts, 3u); + EXPECT_EQ(again.reason, SharedData::UploadFailureReason::LockedLocally); + EXPECT_EQ( + pathsOf(failures_.failuresOf(watch_)), (std::vector{"/watched/a.txt", "/watched/b.txt"}) + ); + } + + TEST_F(FailureListTest, ARetriedPathThatUploadedStartsAfreshWhenItFailsAgain) + { + const auto first = failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.retry(first.failureId); + + EXPECT_FALSE(failures_.removePath(watch_, localPathOf("/watched/a.txt")).has_value()); + + const auto again = failures_.record(failureAt(watch_, "/watched/a.txt")); + EXPECT_NE(again.failureId, first.failureId); + EXPECT_EQ(again.attempts, 1u); + } + + TEST_F(FailureListTest, RetriedFailuresAreForgottenWithinAnUploadedDirectoryAndWithTheirWatch) + { + const auto below = failures_.record(failureAt(watch_, "/watched/dir/a.txt")); + const auto leftOut = failures_.record(failureAt(watch_, "/watched/dir/b.txt")); + const auto elsewhere = failures_.record(failureAt(watch_, "/watched/other.txt")); + failures_.retry(below.failureId); + failures_.retry(leftOut.failureId); + failures_.retry(elsewhere.failureId); + + EXPECT_TRUE(failures_ + .removeWithin( + watch_, localPathOf("/watched/dir"), std::set{localPathOf("/watched/dir/b.txt")} + ) + .empty()); + + EXPECT_NE(failures_.record(failureAt(watch_, "/watched/dir/a.txt")).failureId, below.failureId); + EXPECT_EQ(failures_.record(failureAt(watch_, "/watched/dir/b.txt")).failureId, leftOut.failureId); + EXPECT_EQ(failures_.removeWatch(watch_).size(), 2u); + EXPECT_NE(failures_.record(failureAt(watch_, "/watched/other.txt")).failureId, elsewhere.failureId); + } + + TEST_F(FailureListTest, RemovingAllForgetsTheRetriedFailures) + { + const auto retried = failures_.record(failureAt(watch_, "/watched/a.txt")); + failures_.retry(retried.failureId); + + EXPECT_TRUE(failures_.removeAll().empty()); + + EXPECT_NE(failures_.record(failureAt(watch_, "/watched/a.txt")).failureId, retried.failureId); + } + TEST_F(FailureListTest, CountsAreKeptPerWatch) { failures_.record(failureAt(watch_, "/watched/a.txt")); diff --git a/backend/test/backend/test_failure_reason.hpp b/backend/test/backend/test_failure_reason.hpp index 57829d46..ec03c32d 100644 --- a/backend/test/backend/test_failure_reason.hpp +++ b/backend/test/backend/test_failure_reason.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -212,6 +213,26 @@ namespace Test::FailureReason EXPECT_EQ(unknown.detail, "SizeMismatch"); } + TEST(FailureReasonTests, ALocalFileThatCannotBeLookedAtTellsTheSystemsReasonAndMessage) + { + const auto denied = failureInfoOf(std::make_error_code(std::errc::permission_denied)); + EXPECT_EQ(denied.reason, UploadFailureReason::PermissionDeniedLocally); + ASSERT_TRUE(denied.detail.has_value()); + EXPECT_FALSE(denied.detail->empty()); + + EXPECT_EQ( + failureInfoOf(std::make_error_code(std::errc::device_or_resource_busy)).reason, + UploadFailureReason::LockedLocally + ); + EXPECT_EQ( + failureInfoOf(std::make_error_code(std::errc::no_such_file_or_directory)).reason, + UploadFailureReason::UnreadableLocally + ); + EXPECT_EQ( + failureInfoOf(std::make_error_code(std::errc::io_error)).reason, UploadFailureReason::UnreadableLocally + ); + } + TEST(FailureReasonTests, AFailedUploadTellsWhyAndOneWithoutAnErrorIsUnknown) { const auto failed = uploadEndOf( From 6c0ee130772888158a1e9e59e47208fae9a9fab0 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:38:08 +0200 Subject: [PATCH 050/114] Listed failed uploads per watch with Retry and Dismiss - Failed ends, entries a directory upload left out, queue refusals and paths that cannot be checked are listed, a path that uploads clears its failure, a completed directory upload those below it that it did not leave out - Retry takes the failure off and uploads the path like a change, failing again brings back its id with one attempt more; forget and connectionLost drop a watch's failures and tell the view, stopAll tells nothing - Entries a directory upload left out keep what the server was known to have of them, so their retry or next save raises no conflict - AChangeThatCannotBeCheckedIsNotUploaded now also expects its failure to be listed --- .../source/backend/file_tracking/tracker.cpp | 219 +++++++++- backend/test/backend/test_tracker.hpp | 413 +++++++++++++++++- 2 files changed, 615 insertions(+), 17 deletions(-) diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 16ad0deb..00cec95d 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include #include @@ -423,6 +425,15 @@ namespace FileTracking std::uint64_t claimId_; }; + /** + * @brief The ids of the conflicts and failures the view shows of watches. + */ + struct ShownProblems + { + std::vector conflicts{}; + std::vector failures{}; + }; + using OnOpened = std::function const&)>; boost::asio::strand strand; @@ -443,6 +454,8 @@ namespace FileTracking std::uint64_t nextClaimId{0}; /** @brief The conflicts waiting for the user's answer, each with the upload that waits for it. */ ConflictList conflicts{}; + /** @brief The failed uploads waiting for Retry or Dismiss. */ + FailureList failures{}; /** @brief Changes that ended since nothing was on its way any more, see showProgress(). */ std::uint64_t changesDone{0}; SharedData::TrackerProgress shownProgress{}; @@ -950,9 +963,11 @@ namespace FileTracking if (node.empty()) return std::nullopt; Log::info("FileTracking: stopping watch {}", watchId.value()); + ShownProblems shown; + collectShownProblems(watchId, shown); const auto dropped = conflicts.removeWatch(watchId); - for (auto const& listed : dropped) - view->removeConflict(listed.conflict.conflictId); + failures.removeWatch(watchId); + hideProblems(shown); view->removeEntry(watchId); progressChanged(); for (auto const& listed : dropped) @@ -961,6 +976,22 @@ namespace FileTracking return copy ? std::optional{copy->instanceId} : std::nullopt; } + void collectShownProblems(Ids::WatchId const& watchId, ShownProblems& shown) const + { + for (auto const& conflict : conflicts.conflictsOf(watchId)) + shown.conflicts.push_back(conflict.conflictId); + for (auto const& failure : failures.failuresOf(watchId)) + shown.failures.push_back(failure.failureId); + } + + void hideProblems(ShownProblems const& shown) + { + for (auto const& conflictId : shown.conflicts) + view->removeConflict(conflictId); + for (auto const& failureId : shown.failures) + view->removeFailure(failureId); + } + void setOption(Ids::WatchId const& watchId, SharedData::WatchOption option, bool value) { auto* entry = find(watchId); @@ -1010,6 +1041,7 @@ namespace FileTracking releaseInstance(instanceId); claims.clear(); const auto dropped = conflicts.removeAll(); + failures.removeAll(); const auto waiting = std::exchange(waitingOperations, {}); for (auto const& listed : dropped) leaveOrder(listed.context); @@ -1025,21 +1057,19 @@ namespace FileTracking void connectionLost() { auto ended = SharedData::EndedWatches{.reason = SharedData::WatchEndReason::ConnectionLost, .entries = {}}; - std::vector droppedConflicts; + ShownProblems shown; for (auto const& entry : std::views::values(entries)) { ended.entries.push_back(shownEntryOf(entry)); - for (auto const& conflict : conflicts.conflictsOf(entry.watchId)) - droppedConflicts.push_back(conflict.conflictId); + collectShownProblems(entry.watchId, shown); } Log::info("FileTracking: the connection was lost, ending {} watch(es)", ended.entries.size()); // stopAll() forgets the progress shown, this view still shows it. const bool progressShown = shownProgress != SharedData::TrackerProgress{}; stopAll(); - for (auto const& conflictId : droppedConflicts) - view->removeConflict(conflictId); - for (auto const& shown : ended.entries) - view->removeEntry(shown.watchId); + hideProblems(shown); + for (auto const& endedEntry : ended.entries) + view->removeEntry(endedEntry.watchId); if (!ended.entries.empty()) view->showEnded(ended); if (progressShown) @@ -1080,6 +1110,33 @@ namespace FileTracking return recorded; } + /** + * @brief Records what the server has after the upload of @p directory, see rememberStates(), but for the + * entries it left out (@p failedEntries), which keep what the server was known to have of them. + * @return How many entries it recorded. + */ + std::size_t rememberUploadedStates( + Entry& entry, + Paths::LocalPath const& directory, + std::vector const& failedEntries + ) + { + std::vector>> known; + for (auto const& failed : failedEntries) + if (failed.path != directory && failed.path.isWithin(directory) && + !entry.serverContent.hasDirectory(failed.path)) + known.emplace_back(failed.path, entry.serverContent.fileState(failed.path)); + const auto recorded = rememberStates(entry, directory); + for (auto const& [path, state] : known) + { + if (state) + entry.serverContent.rememberFile(path, *state); + else + entry.serverContent.forget(path); + } + return recorded; + } + void rememberLink(Entry& entry, Paths::LocalPath const& path) { const auto remotePath = remotePathOf(entry, path); @@ -1900,6 +1957,8 @@ namespace FileTracking upload.change.path.toUtf8(), Utility::errorMessage(error) ); + if (const auto* entry = find(upload.watchId)) + recordFailure(*entry, upload.change.path, upload.destination, failureInfoOf(error)); finishUpload(upload, UploadOutcome::NotUploaded); } @@ -2021,22 +2080,134 @@ namespace FileTracking void retryFailure(Ids::FailureId const& failureId) { - Log::warn("FileTracking: retrying failure {} is not implemented", failureId.value()); + const auto retried = failures.retry(failureId); + if (!retried) + { + Log::warn("FileTracking: there is no failure {} to retry", failureId.value()); + return; + } + view->removeFailure(failureId); + retryUpload(*retried); + } + + /** + * @brief Uploads the path of @p failure again like a change of it, compared with the server first, also when + * its watch does not upload changes on its own. + */ + void retryUpload(SharedData::UploadFailure const& failure) + { + const auto* entry = find(failure.watchId); + if (!entry) + return; + const auto destination = remotePathOf(*entry, failure.localPath); + if (!destination) + { + Log::warn( + "FileTracking: not retrying '{}' in watch {}: {}", + failure.localPath.toUtf8(), + failure.watchId.value(), + destination.error().toString() + ); + return; + } + Log::info("FileTracking: retrying the upload of '{}'", failure.localPath.toUtf8()); + queueUpload( + Upload{ + .watchId = failure.watchId, + .change = {.path = failure.localPath, .isDirectory = isDirectoryLocally(failure.localPath)}, + .destination = *destination, + } + ); } void dismissFailure(Ids::FailureId const& failureId) { - Log::warn("FileTracking: dismissing failure {} is not implemented", failureId.value()); + if (!failures.remove(failureId)) + { + Log::warn("FileTracking: there is no failure {} to dismiss", failureId.value()); + return; + } + view->removeFailure(failureId); } void retryAllFailures(Ids::WatchId const& watchId) { - Log::warn("FileTracking: retrying the failures of watch {} is not implemented", watchId.value()); + const auto listed = failures.failuresOf(watchId); + if (listed.empty()) + Log::warn("FileTracking: watch {} has no failures to retry", watchId.value()); + for (auto const& failure : listed) + retryFailure(failure.failureId); } void dismissAllFailures(Ids::WatchId const& watchId) { - Log::warn("FileTracking: dismissing the failures of watch {} is not implemented", watchId.value()); + const auto listed = failures.failuresOf(watchId); + if (listed.empty()) + Log::warn("FileTracking: watch {} has no failures to dismiss", watchId.value()); + for (auto const& failure : listed) + dismissFailure(failure.failureId); + } + + /** + * @brief Lists that the upload of @p localPath to @p remotePath failed as @p failure, or counts one attempt + * more of it. Not for a path that is gone or moved away, its deletion or move follows. + * @return Whether it was listed. + */ + bool recordFailure( + Entry const& entry, + Paths::LocalPath const& localPath, + Paths::RemotePath const& remotePath, + UploadFailureInfo const& failure + ) + { + if (entry.pendingUploads.movedAway(localPath) || isGoneLocally(localPath)) + return false; + const auto listed = failures.record({ + .failureId = {}, + .watchId = entry.watchId, + .localPath = localPath, + .remotePath = remotePath, + .reason = failure.reason, + .detail = failure.detail, + .failedAt = secondsSinceEpochNow(), + .attempts = 1u, + }); + Log::warn( + "FileTracking: uploading '{}' failed ({}, attempt {}): {}", + localPath.toUtf8(), + Utility::enumToString(listed.reason), + listed.attempts, + listed.detail.value_or("") + ); + view->showFailure(listed); + return true; + } + + /** + * @brief @p localPath of the watch @p watchId uploaded, its failure is over. + */ + void clearFailure(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) + { + if (const auto cleared = failures.removePath(watchId, localPath)) + view->removeFailure(cleared->failureId); + } + + /** + * @brief The directory upload @p upload completed but left out @p failedEntries: they are listed, the failures + * at and below the directory that it did not leave out are over. + */ + void + recordLeftOut(Entry const& entry, Upload const& upload, std::vector const& failedEntries) + { + std::set leftOut; + for (auto const& failed : failedEntries) + { + const auto remotePath = remotePathOfChange(failed.path, upload.change.path, upload.destination); + if (remotePath && recordFailure(entry, failed.path, *remotePath, failed.failure)) + leftOut.insert(failed.path); + } + for (auto const& cleared : failures.removeWithin(entry.watchId, upload.change.path, leftOut)) + view->removeFailure(cleared.failureId); } void removeStopped(Ids::WatchId const& watchId) @@ -2111,6 +2282,13 @@ namespace FileTracking return; // A temporary file an editor already renamed or removed ends up here. Log::warn("FileTracking: did not upload '{}': {}", upload.change.path.toUtf8(), queued.error()); + if (const auto* stillWatched = find(upload.watchId)) + recordFailure( + *stillWatched, + upload.change.path, + upload.destination, + {.reason = SharedData::UploadFailureReason::NotQueued, .detail = queued.error()} + ); finishUpload(upload, UploadOutcome::NotUploaded); } @@ -2138,6 +2316,10 @@ namespace FileTracking // Nobody continues the partial file of a watch that ended meanwhile. if (!entry && !upload.change.isDirectory) discardPartial(upload.destination); + if (entry && ended.reason == SharedData::OperationCompletionReason::Failed) + recordFailure( + *entry, upload.change.path, upload.destination, ended.failure.value_or(UploadFailureInfo{}) + ); return finishUpload(upload, UploadOutcome::NotUploaded); } // Uploads carry the local modification time, so the local states are what the server has now. A link @@ -2145,12 +2327,17 @@ namespace FileTracking std::optional carried{}; if (upload.change.isDirectory && !isSymlink(upload.change.path)) { - carried = rememberStates(*entry, upload.change.path); + carried = rememberUploadedStates(*entry, upload.change.path, ended.failedEntries); rememberSentButGone(*entry, upload, ended.sentFiles); removeMovedAway(*entry, upload.change.path); + recordLeftOut(*entry, upload, ended.failedEntries); + } + else + { + if (ended.destinationState) + entry->serverContent.rememberFile(upload.change.path, *ended.destinationState); + clearFailure(entry->watchId, upload.change.path); } - else if (ended.destinationState) - entry->serverContent.rememberFile(upload.change.path, *ended.destinationState); for (auto const& sent : ended.sentFiles) if (sent.state && sent.path.isWithin(upload.change.path)) entry->serverContent.rememberSent(sent.path, *sent.state); diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 485bcf38..be4d684d 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -2844,10 +2844,22 @@ namespace Test std::error_code errorCode{}; std::filesystem::permissions(source, std::filesystem::perms::owner_all, errorCode); }}; - runFor(std::chrono::milliseconds{1000}); + ASSERT_TRUE(runUntil( + [this]() + { + return !view_->failures.empty(); + } + )); + runFor(std::chrono::milliseconds{500}); EXPECT_EQ(uploadsTo("/srv/project/src/new.cpp"), 0u); EXPECT_TRUE(remote_->of("remove").empty()); + ASSERT_EQ(view_->failures.size(), 1u); + const auto failure = view_->failures.begin()->second; + EXPECT_EQ(failure.localPath.native(), source / "new.cpp"); + EXPECT_EQ(failure.remotePath.value(), "/srv/project/src/new.cpp"); + EXPECT_EQ(failure.reason, SharedData::UploadFailureReason::PermissionDeniedLocally); + EXPECT_TRUE(failure.detail.has_value()); } #endif @@ -3413,4 +3425,403 @@ namespace Test completeEverything(); EXPECT_EQ(remote_->server.at("/srv/project/b.bin"), stateOf(renamed)); } + + /** + * @brief Watches notes.txt and the project, both uploaded, and fails the uploads of their saves as told. + */ + class TrackerFailuresTest : public TrackerTest + { + protected: + std::filesystem::path notes_; + std::filesystem::path main_; + std::filesystem::path util_; + FakeRemote::Queued notQueued_{}; + + void SetUp() override + { + TrackerTest::SetUp(); + notes_ = root_ / "local" / "notes.txt"; + main_ = root_ / "local" / "project" / "src" / "main.cpp"; + util_ = root_ / "local" / "project" / "src" / "util.cpp"; + write(util_, "util"); + } + + void watchNotes() + { + ASSERT_TRUE(tracker_->uploadAndWatch(local(notes_), remote("/srv/notes.txt")).has_value()); + completeEverything(); + } + + void watchProject() + { + ASSERT_TRUE( + tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value() + ); + completeEverything(); + } + + static FileTracking::UploadEnd + failedAs(SharedData::UploadFailureReason reason, std::optional detail = std::nullopt) + { + return { + .reason = SharedData::OperationCompletionReason::Failed, + .failure = FileTracking::UploadFailureInfo{.reason = reason, .detail = std::move(detail)}, + }; + } + + /** + * @brief Writes @p content to @p path and runs until the upload number @p count to @p destination is queued. + * @return That upload, or one that cannot end when none was queued. + */ + FakeRemote::Queued& save( + std::filesystem::path const& path, + std::string const& content, + std::string const& destination, + std::size_t count + ) + { + write(path, content); + const bool queued = runUntil( + [this, &destination, count]() + { + return uploadsTo(destination) == count; + } + ); + EXPECT_TRUE(queued) << destination; + return queued ? *lastUploadTo(destination) : notQueued_; + } + + SharedData::UploadFailure onlyFailure() const + { + EXPECT_EQ(view_->failures.size(), 1u); + return view_->failures.empty() ? SharedData::UploadFailure{} : view_->failures.begin()->second; + } + + std::optional shownFailureAt(std::filesystem::path const& localPath) const + { + for (auto const& failure : std::views::values(view_->failures)) + if (failure.localPath.native() == localPath) + return failure; + return std::nullopt; + } + + Ids::FailureId failureIdAt(std::filesystem::path const& localPath) const + { + return shownFailureAt(localPath).value_or(SharedData::UploadFailure{}).failureId; + } + }; + + TEST_F(TrackerFailuresTest, AFailedUploadIsListedWithItsReasonAndDetailAndNothingRetriesIt) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), + failedAs(SharedData::UploadFailureReason::PermissionDeniedOnServer, "Permission denied") + ); + + ASSERT_EQ(view_->failures.size(), 1u); + const auto failure = onlyFailure(); + EXPECT_EQ(failure.watchId, onlyEntry().watchId); + EXPECT_EQ(failure.localPath.native(), notes_); + EXPECT_EQ(failure.remotePath.value(), "/srv/notes.txt"); + EXPECT_EQ(failure.reason, SharedData::UploadFailureReason::PermissionDeniedOnServer); + EXPECT_EQ(failure.detail, std::optional{"Permission denied"}); + EXPECT_EQ(failure.attempts, 1u); + EXPECT_GT(failure.failedAt, 0u); + runFor(std::chrono::milliseconds{500}); + EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); + EXPECT_FALSE(onlyEntry().uploading); + } + + TEST_F(TrackerFailuresTest, FailingAgainKeepsTheIdAndCountsAttemptsUntilAGoodUploadClearsIt) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "first", "/srv/notes.txt", 2u), + failedAs(SharedData::UploadFailureReason::ConnectionProblem, "Channel closed") + ); + const auto first = onlyFailure(); + + endAs(save(notes_, "second", "/srv/notes.txt", 3u), failedAs(SharedData::UploadFailureReason::NoSpaceOnServer)); + + const auto again = onlyFailure(); + EXPECT_EQ(again.failureId, first.failureId); + EXPECT_EQ(again.attempts, 2u); + EXPECT_EQ(again.reason, SharedData::UploadFailureReason::NoSpaceOnServer); + EXPECT_FALSE(again.detail.has_value()); + EXPECT_TRUE(view_->removedFailures.empty()); + + save(notes_, "third", "/srv/notes.txt", 4u); + completeUploads(); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->removedFailures, std::vector{first.failureId.value()}); + + endAs(save(notes_, "fourth", "/srv/notes.txt", 5u), failedAs(SharedData::UploadFailureReason::Unknown)); + EXPECT_NE(onlyFailure().failureId, first.failureId); + EXPECT_EQ(onlyFailure().attempts, 1u); + } + + TEST_F(TrackerFailuresTest, AnUploadTheQueueRefusedIsListedAsNotQueued) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), + failedAs(SharedData::UploadFailureReason::NotQueued, "The queue is closed") + ); + + EXPECT_EQ(onlyFailure().reason, SharedData::UploadFailureReason::NotQueued); + EXPECT_EQ(onlyFailure().detail, std::optional{"The queue is closed"}); + } + + TEST_F(TrackerFailuresTest, AFailedEndWithoutAReasonIsListedAsUnknownAndACancelledOneIsNoFailure) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), + {.reason = SharedData::OperationCompletionReason::Canceled} + ); + EXPECT_TRUE(view_->failures.empty()); + + endAs( + save(notes_, "changed again", "/srv/notes.txt", 3u), + {.reason = SharedData::OperationCompletionReason::Failed} + ); + EXPECT_EQ(onlyFailure().reason, SharedData::UploadFailureReason::Unknown); + } + + TEST_F(TrackerFailuresTest, ADirectoryUploadListsTheEntriesItLeftOutAndClearsTheRest) + { + const auto project = root_ / "local" / "project"; + ASSERT_TRUE(tracker_->uploadAndWatch(local(project), remote("/srv/project")).has_value()); + endAs(*lastUploadTo("/srv/project"), failedAs(SharedData::UploadFailureReason::ConnectionProblem)); + const auto projectFailure = failureIdAt(project); + endAs( + save(main_, "changed main", "/srv/project/src/main.cpp", 1u), + failedAs(SharedData::UploadFailureReason::Unknown) + ); + const auto mainFailure = failureIdAt(main_); + ASSERT_EQ(view_->failures.size(), 2u); + + tracker_->retryFailure(projectFailure); + + EXPECT_EQ(view_->removedFailures, std::vector{projectFailure.value()}); + ASSERT_EQ(uploadsTo("/srv/project"), 2u); + serve(project, "/srv/project"); + remote_->server.erase("/srv/project/src/util.cpp"); + auto sent = sentFilesOf(project); + std::erase_if( + sent, + [this](FileTracking::SentFile const& file) + { + return file.path.native() == util_; + } + ); + endAs( + *lastUploadTo("/srv/project"), + {.reason = SharedData::OperationCompletionReason::Completed, + .sentFiles = sent, + .failedEntries = { + {.path = local(util_), + .failure = { + .reason = SharedData::UploadFailureReason::PermissionDeniedLocally, .detail = "denied" + }} + }} + ); + + ASSERT_EQ(view_->failures.size(), 1u); + const auto leftOut = onlyFailure(); + EXPECT_EQ(leftOut.localPath.native(), util_); + EXPECT_EQ(leftOut.remotePath.value(), "/srv/project/src/util.cpp"); + EXPECT_EQ(leftOut.reason, SharedData::UploadFailureReason::PermissionDeniedLocally); + EXPECT_EQ(leftOut.attempts, 1u); + EXPECT_EQ(view_->removedFailures, (std::vector{projectFailure.value(), mainFailure.value()})); + + // The server is not taken to have what the upload left out: its retry expects nothing there. + tracker_->retryFailure(leftOut.failureId); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/project/src/util.cpp") == 1u; + } + )); + auto const& expected = lastUploadTo("/srv/project/src/util.cpp")->upload.expectedDestination; + ASSERT_TRUE(expected.has_value()); + EXPECT_FALSE(expected->file.has_value()); + completeUploads(); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_EQ(remote_->server.at("/srv/project/src/util.cpp"), stateOf(util_)); + } + + TEST_F(TrackerFailuresTest, ARetryUploadsAgainAndClearsTheFailureOnceItWentThrough) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), failedAs(SharedData::UploadFailureReason::Unknown) + ); + const auto failureId = onlyFailure().failureId; + + tracker_->retryFailure(failureId); + + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->removedFailures, std::vector{failureId.value()}); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/notes.txt") == 3u; + } + )); + // Compared with the server like any change. + EXPECT_TRUE(lastUploadTo("/srv/notes.txt")->upload.expectedDestination.has_value()); + completeUploads(); + EXPECT_EQ(remote_->server.at("/srv/notes.txt"), stateOf(notes_)); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_FALSE(onlyEntry().uploading); + + endAs(save(notes_, "changed again", "/srv/notes.txt", 4u), failedAs(SharedData::UploadFailureReason::Unknown)); + EXPECT_NE(onlyFailure().failureId, failureId); + EXPECT_EQ(onlyFailure().attempts, 1u); + } + + TEST_F(TrackerFailuresTest, ARetryThatFailsAgainKeepsTheIdAndCountsTheAttempt) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), + failedAs(SharedData::UploadFailureReason::LockedLocally, "locked") + ); + const auto failureId = onlyFailure().failureId; + + tracker_->retryFailure(failureId); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/notes.txt") == 3u; + } + )); + endAs( + *lastUploadTo("/srv/notes.txt"), failedAs(SharedData::UploadFailureReason::LockedLocally, "still locked") + ); + + const auto again = onlyFailure(); + EXPECT_EQ(again.failureId, failureId); + EXPECT_EQ(again.attempts, 2u); + EXPECT_EQ(again.detail, std::optional{"still locked"}); + } + + TEST_F(TrackerFailuresTest, RetryAllRetriesEveryFailureOfTheWatchAndARetryCanRaiseAConflict) + { + ASSERT_NO_FATAL_FAILURE(watchProject()); + endAs( + save(main_, "changed main", "/srv/project/src/main.cpp", 1u), + failedAs(SharedData::UploadFailureReason::Unknown) + ); + endAs( + save(util_, "changed util", "/srv/project/src/util.cpp", 1u), + failedAs(SharedData::UploadFailureReason::Unknown) + ); + ASSERT_EQ(view_->failures.size(), 2u); + remote_->server["/srv/project/src/util.cpp"].mtime += 100u; + const auto uploadsBefore = remote_->of("upload").size(); + + tracker_->retryAllFailures(onlyEntry().watchId); + + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->removedFailures.size(), 2u); + ASSERT_TRUE(runUntil( + [this, uploadsBefore]() + { + return remote_->of("upload").size() == uploadsBefore + 2u; + } + )); + const auto uploads = remote_->sourcesOf("upload"); + EXPECT_EQ(uploads[uploadsBefore], main_.string()); + EXPECT_EQ(uploads[uploadsBefore + 1u], util_.string()); + completeUploads(); + EXPECT_EQ(remote_->server.at("/srv/project/src/main.cpp"), stateOf(main_)); + ASSERT_EQ(view_->conflicts.size(), 1u); + EXPECT_EQ(view_->conflicts.begin()->second.localPath.native(), util_); + EXPECT_TRUE(view_->failures.empty()); + } + + TEST_F(TrackerFailuresTest, DismissTakesAFailureOffAndDismissAllTheRestOfTheWatch) + { + ASSERT_NO_FATAL_FAILURE(watchProject()); + endAs( + save(main_, "changed main", "/srv/project/src/main.cpp", 1u), + failedAs(SharedData::UploadFailureReason::Unknown) + ); + endAs( + save(util_, "changed util", "/srv/project/src/util.cpp", 1u), + failedAs(SharedData::UploadFailureReason::Unknown) + ); + const auto mainFailure = failureIdAt(main_); + const auto utilFailure = failureIdAt(util_); + const auto uploadsBefore = remote_->of("upload").size(); + + tracker_->dismissFailure(mainFailure); + EXPECT_EQ(view_->failures.size(), 1u); + EXPECT_EQ(view_->removedFailures, std::vector{mainFailure.value()}); + + tracker_->dismissFailure(mainFailure); + tracker_->retryFailure(mainFailure); + tracker_->dismissFailure(Ids::generateFailureId()); + tracker_->retryAllFailures(Ids::generateWatchId()); + tracker_->dismissAllFailures(Ids::generateWatchId()); + EXPECT_EQ(view_->failures.size(), 1u); + + tracker_->dismissAllFailures(onlyEntry().watchId); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->removedFailures, (std::vector{mainFailure.value(), utilFailure.value()})); + runFor(std::chrono::milliseconds{200}); + EXPECT_EQ(remote_->of("upload").size(), uploadsBefore); + } + + TEST_F(TrackerFailuresTest, StoppingAWatchDropsItsFailuresAndTellsTheView) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), failedAs(SharedData::UploadFailureReason::Unknown) + ); + const auto failureId = onlyFailure().failureId; + + tracker_->stop(onlyEntry().watchId); + + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->removedFailures, std::vector{failureId.value()}); + tracker_->retryFailure(failureId); + EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); + } + + TEST_F(TrackerFailuresTest, ALostConnectionDropsTheFailuresAndTellsTheView) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), failedAs(SharedData::UploadFailureReason::Unknown) + ); + const auto failureId = onlyFailure().failureId; + + tracker_->connectionLost(); + + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->removedFailures, std::vector{failureId.value()}); + } + + TEST_F(TrackerFailuresTest, StoppingAllDropsTheFailuresWithoutTellingTheView) + { + ASSERT_NO_FATAL_FAILURE(watchNotes()); + endAs( + save(notes_, "changed locally", "/srv/notes.txt", 2u), failedAs(SharedData::UploadFailureReason::Unknown) + ); + const auto failureId = onlyFailure().failureId; + + tracker_->stopAll(); + + EXPECT_EQ(view_->failures.size(), 1u); + EXPECT_TRUE(view_->removedFailures.empty()); + tracker_->retryFailure(failureId); + EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); + } } From 745c688592ff09e4e1cf1628469714a9e843f6a3 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:29:14 +0200 Subject: [PATCH 051/114] Implemented the tracker client: events into the store, one method per RPC --- .../file_tracking/tracker_client.cpp | 355 +++++++++++++++--- 1 file changed, 297 insertions(+), 58 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp index 0c9d0776..2ddb0f6c 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp @@ -1,7 +1,23 @@ #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include #include +#include namespace FileTracking { @@ -15,6 +31,141 @@ namespace FileTracking TrackingStore* store; std::function onEnded; Ids::SessionId sessionId{}; + std::vector listeners{}; + Utility::LifetimeToken lifetime{}; + + /** + * @brief Shows what RPC replies and tracker events changed, they arrive outside of DOM event handlers. + */ + static void syncView() + { + Nui::globalEventContext.executeActiveEventsImmediately(); + } + + static bool succeeded(Nui::val const& response) + { + return response.hasOwnProperty("success") && response["success"].as(); + } + + /** + * @brief The translated error of a failed reply, nothing when it has none. + */ + static std::optional errorOf(Nui::val const& response) + { + if (!response.hasOwnProperty("error")) + return std::nullopt; + return language->translate(response["error"].as()); + } + + std::string rpcName(std::string_view function) const + { + return fmt::format("FileTracker::{}::{}", sessionId.value(), function); + } + + /** + * @brief Forwards @p function with @p arguments to the tracker, a refusal is only logged. + */ + template + void forward(std::string_view function, ArgumentsT&&... arguments) + { + Nui::RpcClient::callWithBackChannel( + rpcName(function), + [function = std::string{function}](Nui::val response) + { + if (!succeeded(response)) + Log::error("FileTracker::{} failed: {}", function, errorOf(response).value_or("")); + }, + std::forward(arguments)... + ); + } + + /** + * @brief Registers @p event of the tracker, @p handler gets its payload and the view syncs once after it. + */ + template + void listenTo(std::string_view event, HandlerT handler) + { + listeners.push_back( + Nui::RpcClient::autoRegisterFunction( + rpcName(event), + lifetime.guarded( + [handler = std::move(handler)](PayloadT const& payload) + { + handler(payload); + syncView(); + } + ) + ) + ); + } + + void listen() + { + listenTo( + "onEntry", + [this](SharedData::TrackedEntry const& entry) + { + store->showEntry(entry); + } + ); + listenTo( + "onEntryRemoved", + [this](std::string const& watchId) + { + store->removeEntry(Ids::makeWatchId(watchId)); + } + ); + listenTo( + "onEntriesEnded", + [this](SharedData::EndedWatches const& ended) + { + if (onEnded) + onEnded(ended); + } + ); + listenTo( + "onConflict", + [this](SharedData::WatchConflict const& conflict) + { + store->showConflict(conflict); + } + ); + listenTo( + "onConflictRemoved", + [this](std::string const& conflictId) + { + store->removeConflict(Ids::makeConflictId(conflictId)); + } + ); + listenTo( + "onFailure", + [this](SharedData::UploadFailure const& failure) + { + store->showFailure(failure); + } + ); + listenTo( + "onFailureRemoved", + [this](std::string const& failureId) + { + store->removeFailure(Ids::makeFailureId(failureId)); + } + ); + listenTo( + "onWatchProgress", + [this](SharedData::WatchProgress const& progress) + { + store->showWatchProgress(progress); + } + ); + listenTo( + "onOrphanedCopies", + [this](SharedData::OrphanedCopies const& copies) + { + store->showOrphanedCopies(copies); + } + ); + } }; TrackerClient::TrackerClient( @@ -26,92 +177,180 @@ namespace FileTracking ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(TrackerClient); - void TrackerClient::activate([[maybe_unused]] Ids::SessionId sessionId) - {} + void TrackerClient::activate(Ids::SessionId sessionId) + { + deactivate(); + impl_->sessionId = std::move(sessionId); + impl_->listen(); + listOrphanedCopies(); + } void TrackerClient::deactivate() - {} + { + impl_->listeners.clear(); + impl_->store->clear(); + impl_->sessionId = {}; + } void TrackerClient::open( - [[maybe_unused]] Paths::RemotePath const& remotePath, - [[maybe_unused]] std::uint64_t sizeBytes, - [[maybe_unused]] bool watch, - [[maybe_unused]] std::function - onOpened, - [[maybe_unused]] std::function onRefused + Paths::RemotePath const& remotePath, + std::uint64_t sizeBytes, + bool watch, + std::function onOpened, + std::function onRefused ) - {} + { + Nui::RpcClient::callWithBackChannel( + impl_->rpcName("open"), + impl_->lifetime.guarded( + [remotePath, onOpened = std::move(onOpened), onRefused = std::move(onRefused)](Nui::val response) + { + if (!Implementation::succeeded(response)) + { + if (const auto error = Implementation::errorOf(response)) + return onRefused(*error); + return Log::warn("FileTracking: '{}' was not downloaded, it is not opened", remotePath.value()); + } + const auto localPath = Paths::LocalPath::fromUtf8(response["localPath"].as()); + if (!localPath) + return onRefused(localPath.error().toString()); + onOpened(Ids::makeInstanceId(response["instanceId"].as()), *localPath); + } + ), + remotePath.value(), + static_cast(sizeBytes), + watch + ); + } void TrackerClient::checkUploadAndWatch( - [[maybe_unused]] Paths::LocalPath const& item, - [[maybe_unused]] Paths::RemotePath const& directory, - [[maybe_unused]] std::function onWatchable, - [[maybe_unused]] std::function onRefused + Paths::LocalPath const& item, + Paths::RemotePath const& directory, + std::function onWatchable, + std::function onRefused ) - {} + { + Nui::RpcClient::callWithBackChannel( + impl_->rpcName("checkUploadAndWatch"), + impl_->lifetime.guarded( + [onWatchable = std::move(onWatchable), onRefused = std::move(onRefused)](Nui::val response) + { + if (!Implementation::succeeded(response)) + return onRefused(Implementation::errorOf(response).value_or("")); + const auto canonicalItem = Paths::LocalPath::fromUtf8(response["item"].as()); + if (!canonicalItem) + return onRefused(canonicalItem.error().toString()); + onWatchable(*canonicalItem); + } + ), + item.toUtf8(), + directory.value() + ); + } void TrackerClient::uploadAndWatch( - [[maybe_unused]] Paths::LocalPath const& item, - [[maybe_unused]] Paths::RemotePath const& remotePath, - [[maybe_unused]] std::function onRefused + Paths::LocalPath const& item, + Paths::RemotePath const& remotePath, + std::function onRefused ) - {} + { + Nui::RpcClient::callWithBackChannel( + impl_->rpcName("uploadAndWatch"), + impl_->lifetime.guarded( + [onRefused = std::move(onRefused)](Nui::val response) + { + if (!Implementation::succeeded(response)) + onRefused(Implementation::errorOf(response).value_or("")); + } + ), + item.toUtf8(), + remotePath.value() + ); + } - void TrackerClient::discard( - [[maybe_unused]] Ids::InstanceId const& instanceId, - [[maybe_unused]] Paths::LocalPath const& localPath - ) - {} + void TrackerClient::discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) + { + impl_->forward("discard", instanceId.value(), localPath.toUtf8()); + } - void TrackerClient::stop([[maybe_unused]] Ids::WatchId const& watchId) - {} + void TrackerClient::stop(Ids::WatchId const& watchId) + { + impl_->forward("stop", watchId.value()); + } - void TrackerClient::setOption( - [[maybe_unused]] Ids::WatchId const& watchId, - [[maybe_unused]] SharedData::WatchOption option, - [[maybe_unused]] bool enabled - ) - {} + void TrackerClient::setOption(Ids::WatchId const& watchId, SharedData::WatchOption option, bool enabled) + { + impl_->forward("setOption", watchId.value(), Utility::enumToString(option), enabled); + } - void TrackerClient::answerConflict( - [[maybe_unused]] Ids::ConflictId const& conflictId, - [[maybe_unused]] SharedData::ConflictResolution resolution - ) - {} + void TrackerClient::answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + { + impl_->forward("answerConflict", conflictId.value(), Utility::enumToString(resolution)); + } - void TrackerClient::answerAllConflicts( - [[maybe_unused]] Ids::WatchId const& watchId, - [[maybe_unused]] SharedData::ConflictResolution resolution - ) - {} + void TrackerClient::answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) + { + impl_->forward("answerAllConflicts", watchId.value(), Utility::enumToString(resolution)); + } - void TrackerClient::retryFailure([[maybe_unused]] Ids::FailureId const& failureId) - {} + void TrackerClient::retryFailure(Ids::FailureId const& failureId) + { + impl_->forward("retryFailure", failureId.value()); + } - void TrackerClient::dismissFailure([[maybe_unused]] Ids::FailureId const& failureId) - {} + void TrackerClient::dismissFailure(Ids::FailureId const& failureId) + { + impl_->forward("dismissFailure", failureId.value()); + } - void TrackerClient::retryAllFailures([[maybe_unused]] Ids::WatchId const& watchId) - {} + void TrackerClient::retryAllFailures(Ids::WatchId const& watchId) + { + impl_->forward("retryAllFailures", watchId.value()); + } - void TrackerClient::dismissAllFailures([[maybe_unused]] Ids::WatchId const& watchId) - {} + void TrackerClient::dismissAllFailures(Ids::WatchId const& watchId) + { + impl_->forward("dismissAllFailures", watchId.value()); + } - void TrackerClient::removeStopped([[maybe_unused]] Ids::WatchId const& watchId) - {} + void TrackerClient::removeStopped(Ids::WatchId const& watchId) + { + impl_->forward("removeStopped", watchId.value()); + } void TrackerClient::removeAllStopped() - {} + { + impl_->forward("removeAllStopped"); + } void TrackerClient::listOrphanedCopies() - {} + { + impl_->forward("listOrphanedCopies"); + } void TrackerClient::removeOrphanedCopy( - [[maybe_unused]] Ids::InstanceId const& instanceId, - [[maybe_unused]] std::function onFailed + Ids::InstanceId const& instanceId, + std::function onFailed ) - {} + { + Nui::RpcClient::callWithBackChannel( + impl_->rpcName("removeOrphanedCopy"), + impl_->lifetime.guarded( + [onFailed = std::move(onFailed)](Nui::val response) + { + if (Implementation::succeeded(response)) + return; + if (onFailed) + onFailed(Implementation::errorOf(response).value_or("")); + Implementation::syncView(); + } + ), + instanceId.value() + ); + } void TrackerClient::removeAllOrphanedCopies() - {} + { + impl_->forward("removeAllOrphanedCopies"); + } } From de270302a25ae7d77f2bb4369283b76a99de088c Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:13 +0200 Subject: [PATCH 052/114] Projected the tracking model onto the observed items, groups and selection --- .../file_tracking/tracking_store.cpp | 708 +++++++++++++++++- 1 file changed, 670 insertions(+), 38 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp index 40e93e03..3062afa6 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp @@ -1,20 +1,260 @@ #include +#include +#include #include +#include +#include #include +#include #include +#include +#include +#include +#include +#include #include +#include namespace FileTracking { + namespace + { + template + struct IsVector : std::false_type + {}; + + template + struct IsVector> : std::true_type + {}; + + /** + * @brief Compares described structs member by member, optionals and vectors element by element, everything + * else with ==. The shared types have no operator== of their own. + */ + template + bool sameValue(T const& lhs, T const& rhs) + { + if constexpr (boost::describe::has_describe_members::value) + { + auto same = true; + boost::mp11::mp_for_each>( + [&](auto member) + { + same = same && sameValue(lhs.*member.pointer, rhs.*member.pointer); + } + ); + return same; + } + else if constexpr (Utility::IsOptional_v) + return lhs.has_value() == rhs.has_value() && (!lhs.has_value() || sameValue(*lhs, *rhs)); + else if constexpr (IsVector::value) + return std::ranges::equal( + lhs, + rhs, + [](auto const& left, auto const& right) + { + return sameValue(left, right); + } + ); + else + return lhs == rhs; + } + + /** + * @brief Assigns @p value to @p observed only when it differs, so nothing that shows it updates in vain. + */ + template + requires std::same_as, ValueT> + void assignIfChanged(Nui::Observed& observed, OtherT&& value) + { + if (sameValue(observed.value(), value)) + return; + observed = std::forward(value); + } + + /** + * @return For each of @p values whether it belongs to one longest increasing subsequence of them. The values + * are distinct. + */ + std::vector longestIncreasing(std::vector const& values) + { + std::vector tails{}; + std::vector> previous(values.size()); + for (std::size_t index = 0u; index < values.size(); ++index) + { + const auto tail = std::ranges::lower_bound( + tails, + values[index], + {}, + [&values](std::size_t tailIndex) + { + return values[tailIndex]; + } + ); + const auto length = static_cast(std::distance(tails.begin(), tail)); + if (length > 0u) + previous[index] = tails[length - 1u]; + if (length == tails.size()) + tails.push_back(index); + else + tails[length] = index; + } + std::vector kept(values.size(), false); + auto current = tails.empty() ? std::nullopt : std::optional{tails.back()}; + while (current) + { + kept[*current] = true; + current = previous[*current]; + } + return kept; + } + + /** + * @brief Turns @p observed into @p target with the fewest erases and inserts, comparing items by identity: + * the longest run of items already in target order stays, everything else is erased or inserted. A + * range over @p observed then renders only what moved, came or went. + */ + template + void syncList( + Nui::Observed>>& observed, + std::vector> const& target + ) + { + auto const& current = observed.value(); + if (current == target) + return; + + std::unordered_map targetPositions{}; + for (std::size_t index = 0u; index < target.size(); ++index) + targetPositions.emplace(target[index].get(), index); + + std::vector wantedIndices{}; + std::vector wantedPositions{}; + for (std::size_t index = 0u; index < current.size(); ++index) + { + if (const auto found = targetPositions.find(current[index].get()); found != targetPositions.end()) + { + wantedIndices.push_back(index); + wantedPositions.push_back(found->second); + } + } + const auto stable = longestIncreasing(wantedPositions); + std::vector keep(current.size(), false); + for (std::size_t wanted = 0u; wanted < wantedIndices.size(); ++wanted) + keep[wantedIndices[wanted]] = stable[wanted]; + + auto runEnd = current.size(); + while (runEnd > 0u) + { + if (keep[runEnd - 1u]) + { + --runEnd; + continue; + } + auto runBegin = runEnd - 1u; + while (runBegin > 0u && !keep[runBegin - 1u]) + --runBegin; + observed.erase( + current.cbegin() + static_cast(runBegin), + current.cbegin() + static_cast(runEnd) + ); + runEnd = runBegin; + } + + std::size_t index = 0u; + while (index < target.size()) + { + if (index < current.size() && current[index] == target[index]) + { + ++index; + continue; + } + auto insertEnd = index + 1u; + while (insertEnd < target.size() && (index >= current.size() || current[index] != target[insertEnd])) + ++insertEnd; + observed.insert( + current.cbegin() + static_cast(index), + target.begin() + static_cast(index), + target.begin() + static_cast(insertEnd) + ); + index = insertEnd; + } + } + + /** + * @brief Updates the rows of @p observed from @p values in their order: an item whose value did not change is + * kept, a changed or new one is made by @p makeItem, all of them when @p rebuild is set. + */ + template + void syncRows( + Nui::Observed>>& observed, + std::vector const& values, + ValueT ItemT::* valueOfItem, + KeyOfT keyOf, + MakeItemT makeItem, + bool rebuild + ) + { + using KeyT = std::remove_cvref_t>; + std::map> known{}; + if (!rebuild) + { + for (auto const& item : observed.value()) + known.emplace(keyOf((*item).*valueOfItem), item); + } + std::vector> target{}; + target.reserve(values.size()); + for (auto const& value : values) + { + const auto found = known.find(keyOf(value)); + if (found != known.end() && sameValue((*found->second).*valueOfItem, value)) + target.push_back(found->second); + else + target.push_back(std::make_shared(makeItem(value))); + } + syncList(observed, target); + } + + std::size_t indexOf(OutlinerGroup group) + { + return static_cast(group); + } + + /** + * @brief What of a watch an event changed, beside its status, texts and group, which always follow. + */ + struct WatchParts + { + bool entry{false}; + bool progress{false}; + bool conflicts{false}; + bool failures{false}; + }; + + constexpr WatchParts everyPart{.entry = true, .progress = true, .conflicts = true, .failures = true}; + } + struct TrackingStore::Implementation { + /** + * @brief A known watch: its item, the group it is listed in, and the rows of its files in flight. + */ + struct WatchRecord + { + std::shared_ptr item{}; + std::optional group{}; + InFlightRows rows{}; + }; + explicit Implementation(FileTrackingTexts texts) : texts{std::move(texts)} {} FileTrackingTexts texts; TrackingModel model{}; + std::map watches{}; + std::map> orphanItems{}; std::array>>, outlinerGroupOrder.size()> members{}; Nui::Observed>> orphans{}; std::array>, outlinerGroupOrder.size()> counts{ @@ -30,17 +270,278 @@ namespace FileTracking std::make_shared>(Utility::enumToString(DetailTab::Conflicts)) }; Nui::Observed filter{}; + /** @brief The filter the member lists follow, the observed one may run ahead of it while being typed. */ + std::string appliedFilter{}; std::array expanded{true, true, true, true, true}; std::function onAttentionChanged{}; - }; + AttentionCounts attentionCounts{}; - namespace - { - std::size_t indexOf(OutlinerGroup group) + void selectNothing() { - return static_cast(group); + if (!std::holds_alternative(selection.value())) + selection = Selection{}; + } + + void notifyAttention() + { + const AttentionCounts current{.conflicts = model.conflictCount(), .failed = model.failureCount()}; + if (current == attentionCounts) + return; + attentionCounts = current; + if (onAttentionChanged) + onAttentionChanged(current); + } + + void refreshWatchMembers(OutlinerGroup group) + { + std::vector> target{}; + for (auto const& member : model.members(group, appliedFilter)) + { + if (auto const* watchId = std::get_if(&member)) + { + if (const auto watch = watches.find(*watchId); watch != watches.end()) + target.push_back(watch->second.item); + } + } + syncList(members[indexOf(group)], target); + assignIfChanged(*counts[indexOf(group)], static_cast(target.size())); + } + + void refreshOrphanMembers() + { + std::vector> target{}; + for (auto const& member : model.members(OutlinerGroup::Orphaned, appliedFilter)) + { + if (auto const* instanceId = std::get_if(&member)) + { + if (const auto orphan = orphanItems.find(*instanceId); orphan != orphanItems.end()) + target.push_back(orphan->second); + } + } + syncList(orphans, target); + assignIfChanged(*counts[indexOf(OutlinerGroup::Orphaned)], static_cast(target.size())); + } + + void refreshMembers(OutlinerGroup group) + { + if (group == OutlinerGroup::Orphaned) + refreshOrphanMembers(); + else + refreshWatchMembers(group); + } + + void refreshEveryMembers() + { + for (const auto group : outlinerGroupOrder) + refreshMembers(group); + } + + /** + * @brief Rebuilds the orphan items from the model, keeping the items of copies still listed, and drops the + * selection of a copy that is gone. + */ + void refreshOrphans() + { + std::map> listed{}; + for (auto const& member : model.members(OutlinerGroup::Orphaned, {})) + { + auto const* instanceId = std::get_if(&member); + if (instanceId == nullptr) + continue; + auto const* copy = model.orphanOf(*instanceId); + if (copy == nullptr) + continue; + const auto known = orphanItems.find(*instanceId); + auto item = known != orphanItems.end() ? known->second : std::make_shared(*instanceId); + assignIfChanged(item->copy, *copy); + assignIfChanged(item->name, copy->name); + assignIfChanged(item->sideText, sizeText(copy->sizeBytes)); + listed.emplace(*instanceId, std::move(item)); + } + orphanItems = std::move(listed); + refreshOrphanMembers(); + if (auto const* selected = std::get_if(&selection.value()); + selected != nullptr && !orphanItems.contains(*selected)) + selectNothing(); + } + + void showEntryFields(WatchItem& item, SharedData::TrackedEntry const& entry) + { + assignIfChanged(item.entry, entry); + assignIfChanged(item.name, entry.name); + assignIfChanged(item.localPath, entry.localPath.toUtf8()); + assignIfChanged(item.remotePath, entry.remotePath.value()); + assignIfChanged(item.downloadedCopy, entry.downloadedCopy); + assignIfChanged(item.isDirectory, entry.isDirectory); + assignIfChanged(item.stop, entry.stop); + assignIfChanged(item.stopText, entry.stop ? stopReasonText(texts, *entry.stop) : std::string{}); + assignIfChanged(*item.autoReupload, entry.options.autoReupload); + assignIfChanged(*item.moveRemote, entry.options.moveRemoteOnLocalMove); + assignIfChanged(*item.deleteRemote, entry.options.deleteRemoteOnLocalDelete); + assignIfChanged(*item.checkRemote, entry.options.checkRemoteChanges); + } + + /** + * @brief Shows the file of @p row (nothing for a free row) in @p fileItem. @p watchedMoved recomputes the name + * below @p watched even when the row keeps its file. + */ + static void showInFlightFile( + InFlightFileItem& fileItem, + std::optional const& row, + std::vector const& keys, + std::vector const& files, + Paths::LocalPath const& watched, + bool watchedMoved + ) + { + const auto key = row ? std::ranges::find(keys, *row) : keys.end(); + if (key == keys.end()) + { + assignIfChanged(fileItem.path, std::string{}); + assignIfChanged(fileItem.name, std::string{}); + assignIfChanged(*fileItem.totalBytes, std::int64_t{0}); + assignIfChanged(*fileItem.bytes, std::int64_t{0}); + return; + } + auto const& file = files[static_cast(std::distance(keys.begin(), key))]; + if (fileItem.path.value() != *key || watchedMoved) + assignIfChanged(fileItem.name, pathWithin(watched, file.path)); + assignIfChanged(fileItem.path, *key); + assignIfChanged(*fileItem.totalBytes, static_cast(file.totalBytes)); + assignIfChanged(*fileItem.bytes, static_cast(file.bytes)); + } + + static void showInFlight( + WatchRecord& watch, + SharedData::WatchProgress const& progress, + Paths::LocalPath const& watched, + bool watchedMoved + ) + { + std::vector keys{}; + keys.reserve(progress.inFlight.size()); + for (auto const& file : progress.inFlight) + keys.push_back(file.path.toUtf8()); + watch.rows.update(keys); + + auto const& rows = watch.rows.rows(); + auto& inFlight = watch.item->inFlight; + while (inFlight.value().size() > rows.size()) + inFlight.pop_back(); + for (std::size_t index = 0u; index < rows.size(); ++index) + { + const auto isNew = index >= inFlight.value().size(); + auto fileItem = isNew ? std::make_shared() : inFlight.value()[index]; + showInFlightFile(*fileItem, rows[index], keys, progress.inFlight, watched, watchedMoved); + if (isNew) + inFlight.push_back(std::move(fileItem)); + } + } + + void showProgress(WatchRecord& watch, SharedData::TrackedEntry const& entry, bool watchedMoved) + { + auto const* progress = model.progressOf(entry.watchId); + if (progress == nullptr) + return; + auto& item = *watch.item; + assignIfChanged(item.progress, *progress); + assignIfChanged(*item.bytesTotal, static_cast(progress->bytesTotal)); + assignIfChanged(*item.bytesDone, static_cast(progress->bytesDone)); + showInFlight(watch, *progress, entry.localPath, watchedMoved); + } + + void showConflicts(WatchItem& item, SharedData::TrackedEntry const& entry, bool watchedMoved) + { + syncRows( + item.conflicts, + model.conflictsOf(entry.watchId), + &ConflictItem::conflict, + [](SharedData::WatchConflict const& conflict) + { + return conflict.conflictId; + }, + [this, &entry](SharedData::WatchConflict const& conflict) + { + return ConflictItem{ + .conflict = conflict, + .file = pathWithin(entry.localPath, conflict.localPath), + .line = conflictLineText(texts, conflict), + }; + }, + watchedMoved + ); + assignIfChanged(*item.conflictCount, model.conflictCountOf(entry.watchId)); + } + + void showFailures(WatchItem& item, SharedData::TrackedEntry const& entry, bool watchedMoved) + { + syncRows( + item.failures, + model.failuresOf(entry.watchId), + &FailureItem::failure, + [](SharedData::UploadFailure const& failure) + { + return failure.failureId; + }, + [this, &entry](SharedData::UploadFailure const& failure) + { + return FailureItem{ + .failure = failure, + .file = pathWithin(entry.localPath, failure.localPath), + .reason = failureReasonText(texts, failure.reason), + .time = localClockTime(failure.failedAt), + }; + }, + watchedMoved + ); + assignIfChanged(*item.failureCount, model.failureCountOf(entry.watchId)); } - } + + void showSummary(WatchItem& item) + { + assignIfChanged(item.status, model.statusOf(item.watchId)); + assignIfChanged(item.statusText, model.statusTextOf(item.watchId, texts)); + assignIfChanged(item.sideText, model.sideTextOf(item.watchId, texts)); + assignIfChanged(*item.ringFraction, model.ringFractionOf(item.watchId)); + } + + /** + * @brief Updates @p parts of the item of @p watchId from the model, then its status and texts, and moves it + * to another group's list when its group changed. Does nothing for an unknown watch. + */ + void refreshWatch(Ids::WatchId const& watchId, WatchParts parts) + { + const auto watch = watches.find(watchId); + auto const* entry = model.entryOf(watchId); + if (watch == watches.end() || entry == nullptr) + return; + auto& record = watch->second; + auto& item = *record.item; + + const auto renamed = parts.entry && item.name.value() != entry->name; + const auto watchedMoved = parts.entry && item.localPath.value() != entry->localPath.toUtf8(); + if (parts.entry) + showEntryFields(item, *entry); + if (parts.progress || watchedMoved) + showProgress(record, *entry, watchedMoved); + if (parts.conflicts || watchedMoved) + showConflicts(item, *entry, watchedMoved); + if (parts.failures || watchedMoved) + showFailures(item, *entry, watchedMoved); + showSummary(item); + + const auto group = model.groupOf(watchId); + if (record.group != group) + { + const auto previous = std::exchange(record.group, group); + if (previous) + refreshMembers(*previous); + refreshMembers(group); + } + else if (renamed) + refreshMembers(group); + } + }; TrackingStore::TrackingStore(FileTrackingTexts texts) : impl_{std::make_unique(std::move(texts))} @@ -48,32 +549,102 @@ namespace FileTracking ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(TrackingStore); - void TrackingStore::showEntry([[maybe_unused]] SharedData::TrackedEntry const& entry) - {} + void TrackingStore::showEntry(SharedData::TrackedEntry const& entry) + { + auto [watch, inserted] = impl_->watches.try_emplace(entry.watchId); + if (inserted) + watch->second.item = std::make_shared(entry.watchId); + const auto heldInstance = watch->second.item->entry.value().instanceId.has_value(); + + impl_->model.showEntry(entry); + impl_->refreshWatch(entry.watchId, inserted ? everyPart : WatchParts{.entry = true}); + if (heldInstance || entry.instanceId) + impl_->refreshOrphans(); + impl_->notifyAttention(); + } - void TrackingStore::removeEntry([[maybe_unused]] Ids::WatchId const& watchId) - {} + void TrackingStore::removeEntry(Ids::WatchId const& watchId) + { + if (auto const* selected = std::get_if(&impl_->selection.value()); + selected != nullptr && *selected == watchId) + impl_->selectNothing(); - void TrackingStore::showConflict([[maybe_unused]] SharedData::WatchConflict const& conflict) - {} + std::optional group{}; + auto heldInstance = false; + if (const auto watch = impl_->watches.find(watchId); watch != impl_->watches.end()) + { + group = watch->second.group; + heldInstance = watch->second.item->entry.value().instanceId.has_value(); + impl_->watches.erase(watch); + } - void TrackingStore::removeConflict([[maybe_unused]] Ids::ConflictId const& conflictId) - {} + impl_->model.removeEntry(watchId); + if (group) + impl_->refreshMembers(*group); + if (heldInstance) + impl_->refreshOrphans(); + impl_->notifyAttention(); + } - void TrackingStore::showFailure([[maybe_unused]] SharedData::UploadFailure const& failure) - {} + void TrackingStore::showConflict(SharedData::WatchConflict const& conflict) + { + const auto previous = impl_->model.watchOfConflict(conflict.conflictId); + impl_->model.showConflict(conflict); + impl_->refreshWatch(conflict.watchId, {.conflicts = true}); + if (previous && *previous != conflict.watchId) + impl_->refreshWatch(*previous, {.conflicts = true}); + impl_->notifyAttention(); + } - void TrackingStore::removeFailure([[maybe_unused]] Ids::FailureId const& failureId) - {} + void TrackingStore::removeConflict(Ids::ConflictId const& conflictId) + { + const auto watchId = impl_->model.watchOfConflict(conflictId); + impl_->model.removeConflict(conflictId); + if (watchId) + impl_->refreshWatch(*watchId, {.conflicts = true}); + impl_->notifyAttention(); + } - void TrackingStore::showWatchProgress([[maybe_unused]] SharedData::WatchProgress const& progress) - {} + void TrackingStore::showFailure(SharedData::UploadFailure const& failure) + { + const auto previous = impl_->model.watchOfFailure(failure.failureId); + impl_->model.showFailure(failure); + impl_->refreshWatch(failure.watchId, {.failures = true}); + if (previous && *previous != failure.watchId) + impl_->refreshWatch(*previous, {.failures = true}); + impl_->notifyAttention(); + } - void TrackingStore::showOrphanedCopies([[maybe_unused]] SharedData::OrphanedCopies const& copies) - {} + void TrackingStore::removeFailure(Ids::FailureId const& failureId) + { + const auto watchId = impl_->model.watchOfFailure(failureId); + impl_->model.removeFailure(failureId); + if (watchId) + impl_->refreshWatch(*watchId, {.failures = true}); + impl_->notifyAttention(); + } + + void TrackingStore::showWatchProgress(SharedData::WatchProgress const& progress) + { + impl_->model.showWatchProgress(progress); + impl_->refreshWatch(progress.watchId, {.progress = true}); + } + + void TrackingStore::showOrphanedCopies(SharedData::OrphanedCopies const& copies) + { + impl_->model.showOrphanedCopies(copies); + impl_->refreshOrphans(); + } void TrackingStore::clear() - {} + { + impl_->selectNothing(); + impl_->model.clear(); + impl_->watches.clear(); + impl_->orphanItems.clear(); + impl_->refreshEveryMembers(); + impl_->notifyAttention(); + } TrackingModel const& TrackingStore::model() const { @@ -100,14 +671,16 @@ namespace FileTracking return impl_->counts[indexOf(group)]; } - std::shared_ptr TrackingStore::watchItem([[maybe_unused]] Ids::WatchId const& watchId) const + std::shared_ptr TrackingStore::watchItem(Ids::WatchId const& watchId) const { - return nullptr; + const auto watch = impl_->watches.find(watchId); + return watch != impl_->watches.end() ? watch->second.item : nullptr; } - std::shared_ptr TrackingStore::orphanItem([[maybe_unused]] Ids::InstanceId const& instanceId) const + std::shared_ptr TrackingStore::orphanItem(Ids::InstanceId const& instanceId) const { - return nullptr; + const auto orphan = impl_->orphanItems.find(instanceId); + return orphan != impl_->orphanItems.end() ? orphan->second : nullptr; } Nui::Observed& TrackingStore::selection() @@ -115,19 +688,65 @@ namespace FileTracking return impl_->selection; } - void TrackingStore::select([[maybe_unused]] Selection selection) - {} + void TrackingStore::select(Selection selection) + { + assignIfChanged(impl_->selection, std::move(selection)); + } - void TrackingStore::moveSelection([[maybe_unused]] SelectionMove move) - {} + void TrackingStore::moveSelection(SelectionMove move) + { + std::vector shown{}; + for (const auto group : outlinerGroupOrder) + { + if (!impl_->expanded[indexOf(group)]) + continue; + if (group == OutlinerGroup::Orphaned) + { + for (auto const& item : impl_->orphans.value()) + shown.emplace_back(item->instanceId); + } + else + { + for (auto const& item : impl_->members[indexOf(group)].value()) + shown.emplace_back(item->watchId); + } + } + if (shown.empty()) + return; + + const auto last = shown.size() - 1u; + const auto selected = std::ranges::find(shown, impl_->selection.value()); + const auto current = selected != shown.end() + ? std::optional{static_cast(std::distance(shown.begin(), selected))} + : std::nullopt; + const auto target = [move, last, current]() -> std::size_t + { + switch (move) + { + case SelectionMove::First: + return 0u; + case SelectionMove::Last: + return last; + case SelectionMove::Next: + return current ? std::min(*current + 1u, last) : 0u; + case SelectionMove::Previous: + break; + } + return current ? (*current == 0u ? 0u : *current - 1u) : last; + }(); + select(shown[target]); + } DetailTab TrackingStore::detailTab() const { return impl_->detailTab; } - void TrackingStore::setDetailTab([[maybe_unused]] DetailTab tab) - {} + void TrackingStore::setDetailTab(DetailTab tab) + { + impl_->detailTab = tab; + assignIfChanged(*impl_->detailTabKey, Utility::enumToString(tab)); + } std::shared_ptr> TrackingStore::detailTabKey() { @@ -139,20 +758,33 @@ namespace FileTracking return impl_->filter; } - void TrackingStore::setFilter([[maybe_unused]] std::string filter) - {} + void TrackingStore::setFilter(std::string filter) + { + assignIfChanged(impl_->filter, filter); + if (filter == impl_->appliedFilter) + return; + impl_->appliedFilter = std::move(filter); + impl_->refreshEveryMembers(); + } bool TrackingStore::groupExpanded(OutlinerGroup group) const { return impl_->expanded[indexOf(group)]; } - void TrackingStore::setGroupExpanded([[maybe_unused]] OutlinerGroup group, [[maybe_unused]] bool expanded) - {} + void TrackingStore::setGroupExpanded(OutlinerGroup group, bool expanded) + { + impl_->expanded[indexOf(group)] = expanded; + } bool TrackingStore::focusNewestConflict() { - return false; + const auto newest = impl_->model.newestConflict(); + if (!newest) + return false; + select(newest->watchId); + setDetailTab(DetailTab::Conflicts); + return true; } void TrackingStore::onAttentionChanged(std::function onChanged) From f5115c91bd31957a74ea5c7039d687b4be4674be Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:07 +0200 Subject: [PATCH 053/114] Rendered the status and orphan glyphs of the file tracking outliner --- .../file_tracking/status_glyph.cpp | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp index fe4beaa3..91fdf405 100644 --- a/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp +++ b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp @@ -1,16 +1,63 @@ #include +#include -#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include namespace FileTracking { - Nui::ElementRenderer statusGlyph([[maybe_unused]] WatchStatus status) + namespace + { + Nui::ElementRenderer iconOf(WatchStatus status) + { + switch (status) + { + case WatchStatus::Conflicts: + return Ui5Icons::sys_help(); + case WatchStatus::Failed: + return Ui5Icons::warning(); + case WatchStatus::Stopped: + return Ui5Icons::border(); + case WatchStatus::Watching: + case WatchStatus::Syncing: + break; + } + return Ui5Icons::accept(); + } + + Nui::ElementRenderer glyph(std::string status, Nui::ElementRenderer icon) + { + using namespace Nui::Attributes; + using Nui::Elements::span; + + // clang-format off + return span{ + class_ = "file-tracking-glyph", + "data-status"_attr = std::move(status), + "aria-hidden"_attr = "true", + }( + std::move(icon) + ); + // clang-format on + } + } + + Nui::ElementRenderer statusGlyph(WatchStatus status) { - return Nui::nil(); + return glyph(Utility::enumToString(status), iconOf(status)); } Nui::ElementRenderer orphanGlyph() { - return Nui::nil(); + return glyph("Orphaned", Ui5Icons::document()); } } From 6d75878104612af95dbebb88fcbc0e9037ee8b5c Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:11 +0200 Subject: [PATCH 054/114] Rendered the watch and orphan rows of the file tracking outliner --- .../file_tracking/orphan_row.cpp | 84 +++++++++- .../file_tracking/watch_row.cpp | 143 +++++++++++++++++- 2 files changed, 214 insertions(+), 13 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp b/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp index 71f0345e..0e7f1fa6 100644 --- a/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp +++ b/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp @@ -1,15 +1,85 @@ #include +#include +#include -#include +#include +#include +#include + +#include + +#include +#include +#include + +#include + +#include +#include namespace FileTracking { - Nui::ElementRenderer orphanRow( - [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item - ) + namespace { - return Nui::nil(); + bool holdsInstance(Selection const& selection, Ids::InstanceId const& instanceId) + { + auto const* selected = std::get_if(&selection); + return selected != nullptr && *selected == instanceId; + } + + Nui::ElementRenderer trashButton(TrackingActions const& actions, std::shared_ptr const& item) + { + using namespace Nui::Attributes; + namespace Snc = ScriptNuiComponents; + + const auto deleteFormat = language->get("fileTracking", "deleteOrphanTooltip"); + + // clang-format off + const auto tooltip = Nui::observe(item->name).generate([item, deleteFormat]() { + return fmt::format(fmt::runtime(deleteFormat), item->name.value()); + }); + return Snc::button({ + .icon = Ui5Icons::delete_(), + .attributes = { + "data-class"_attr = "file-tracking-trash", + "data-ft-action"_attr = "delete-orphan", + type = "button", + title = tooltip, + "aria-label"_attr = tooltip, + onClick = [&actions, item]() { + actions.deleteOrphanedCopy(item->instanceId); + }, + }, + .styleVariant = Snc::StyleVariant::Transparent, + }); + // clang-format on + } + } + + Nui::ElementRenderer + orphanRow(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) + { + using namespace Nui::Attributes; + namespace Snc = ScriptNuiComponents; + + // clang-format off + return Snc::listRow({ + .leading = orphanGlyph(), + .label = item->name, + .sideText = item->sideText, + .trailing = trashButton(actions, item), + .selected = Nui::observe(store.selection()).generate([&store, item]() { + return holdsInstance(store.selection().value(), item->instanceId); + }), + .onActivate = [&actions, item]() { + actions.select(Selection{item->instanceId}); + }, + .attributes = { + "data-ft-instance-id"_attr = item->instanceId.value(), + "data-ft-name"_attr = item->name, + title = item->name, + }, + }); + // clang-format on } } diff --git a/frontend/source/frontend/session_components/file_tracking/watch_row.cpp b/frontend/source/frontend/session_components/file_tracking/watch_row.cpp index b8591092..084bcb92 100644 --- a/frontend/source/frontend/session_components/file_tracking/watch_row.cpp +++ b/frontend/source/frontend/session_components/file_tracking/watch_row.cpp @@ -1,15 +1,146 @@ #include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include #include +#include + +#include +#include +#include +#include + namespace FileTracking { - Nui::ElementRenderer watchRow( - [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item - ) + namespace + { + bool holdsWatch(Selection const& selection, Ids::WatchId const& watchId) + { + auto const* selected = std::get_if(&selection); + return selected != nullptr && *selected == watchId; + } + + int percentOf(double fraction) + { + return static_cast(std::floor(100.0 * std::clamp(fraction, 0.0, 1.0))); + } + + std::string syncingText(WatchItem const& item, std::string const& syncingFormat) + { + return fmt::format(fmt::runtime(syncingFormat), item.name.value(), percentOf(item.ringFraction->value())); + } + + Nui::ElementRenderer leading(std::shared_ptr const& item, std::string const& syncingFormat) + { + using namespace Nui::Attributes; + using Nui::Elements::span; + namespace Snc = ScriptNuiComponents; + + // clang-format off + return span{class_ = "file-tracking-row-leading"}( + Nui::observe(item->status), + [item, syncingFormat]() -> Nui::ElementRenderer { + if (item->status.value() != WatchStatus::Syncing) + return statusGlyph(item->status.value()); + return Snc::progressRing({ + .fraction = item->ringFraction, + .attributes = { + "aria-label"_attr = Nui::observe(item->name, item->ringFraction).generate( + [item, syncingFormat]() { + return syncingText(*item, syncingFormat); + } + ), + }, + }); + } + ); + // clang-format on + } + + Nui::ElementRenderer trailing(TrackingActions const& actions, std::shared_ptr const& item) + { + using namespace Nui::Attributes; + using Nui::Elements::span; + namespace Snc = ScriptNuiComponents; + + const auto removeFormat = language->get("fileTracking", "removeStoppedTooltip"); + + // clang-format off + return span{class_ = "file-tracking-row-actions"}( + Nui::observe(item->status), + [&actions, item, removeFormat]() -> Nui::ElementRenderer { + if (item->status.value() != WatchStatus::Stopped) + return Nui::nil(); + const auto tooltip = Nui::observe(item->name).generate([item, removeFormat]() { + return fmt::format(fmt::runtime(removeFormat), item->name.value()); + }); + return Snc::button({ + .icon = Ui5Icons::delete_(), + .attributes = { + "data-class"_attr = "file-tracking-trash", + "data-ft-action"_attr = "remove-stopped", + type = "button", + title = tooltip, + "aria-label"_attr = tooltip, + onClick = [&actions, item]() { + actions.removeStopped(item->watchId); + }, + }, + .styleVariant = Snc::StyleVariant::Transparent, + }); + } + ); + // clang-format on + } + } + + Nui::ElementRenderer + watchRow(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) { - return Nui::nil(); + using namespace Nui::Attributes; + namespace Snc = ScriptNuiComponents; + + const auto syncingFormat = language->get("fileTracking", "syncingTooltip"); + + // clang-format off + return Snc::listRow({ + .leading = leading(item, syncingFormat), + .label = item->name, + .sideText = item->sideText, + .trailing = trailing(actions, item), + .selected = Nui::observe(store.selection()).generate([&store, item]() { + return holdsWatch(store.selection().value(), item->watchId); + }), + .onActivate = [&actions, item]() { + actions.select(Selection{item->watchId}); + }, + .attributes = { + "data-ft-watch-id"_attr = item->watchId.value(), + "data-ft-name"_attr = item->name, + "data-ft-state"_attr = Nui::observe(item->status).generate([item]() { + return Utility::enumToString(item->status.value()); + }), + title = Nui::observe(item->name, item->status, item->ringFraction).generate( + [item, syncingFormat]() { + if (item->status.value() == WatchStatus::Syncing) + return syncingText(*item, syncingFormat); + return item->name.value(); + } + ), + }, + }); + // clang-format on } } From b43c32aa437570d55eb031cd16038576a6d01723 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:11 +0200 Subject: [PATCH 055/114] Rendered the file tracking outliner with its filter, groups and keyboard moves --- .../file_tracking/outliner.cpp | 255 +++++++++++++++++- 1 file changed, 252 insertions(+), 3 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/outliner.cpp b/frontend/source/frontend/session_components/file_tracking/outliner.cpp index de6b5a4f..83ccc8a3 100644 --- a/frontend/source/frontend/session_components/file_tracking/outliner.cpp +++ b/frontend/source/frontend/session_components/file_tracking/outliner.cpp @@ -1,12 +1,261 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include + namespace FileTracking { - Nui::ElementRenderer - outliner([[maybe_unused]] TrackingStore& store, [[maybe_unused]] TrackingActions const& actions) + namespace { - return Nui::nil(); + std::string titleOf(OutlinerGroup group) + { + switch (group) + { + case OutlinerGroup::NeedsYou: + return language->get("fileTracking", "groupNeedsYou"); + case OutlinerGroup::Syncing: + return language->get("fileTracking", "groupSyncing"); + case OutlinerGroup::Watching: + return language->get("fileTracking", "statusWatching"); + case OutlinerGroup::Stopped: + return language->get("fileTracking", "groupStopped"); + case OutlinerGroup::Orphaned: + return language->get("fileTracking", "groupOrphaned"); + } + return {}; + } + + std::optional selectionMoveOf(Nui::WebApi::KeyboardEvent const& event) + { + if (event.altKey() || event.ctrlKey() || event.metaKey() || event.shiftKey()) + return std::nullopt; + const auto key = event.key(); + if (key == "ArrowUp") + return SelectionMove::Previous; + if (key == "ArrowDown") + return SelectionMove::Next; + if (key == "Home") + return SelectionMove::First; + if (key == "End") + return SelectionMove::Last; + return std::nullopt; + } + + void moveSelectionByKey(TrackingActions const& actions, Nui::WebApi::KeyboardEvent const& event) + { + const auto move = selectionMoveOf(event); + if (!move) + return; + event.preventDefault(); + actions.moveSelection(*move); + Nui::globalEventContext.executeActiveEventsImmediately(); + const auto selector = std::string{".script-nui-list-row[data-selected]"}; + const auto selected = event.currentTarget().call("querySelector", selector); + if (!selected.isNull()) + selected.call("focus"); + } + + Nui::ElementRenderer + groupTrash(std::string const& action, std::string const& tooltip, std::function const& onRemove) + { + using namespace Nui::Attributes; + namespace Snc = ScriptNuiComponents; + + // clang-format off + return Snc::button({ + .icon = Ui5Icons::delete_(), + .attributes = { + "data-class"_attr = "file-tracking-trash", + "data-ft-action"_attr = action, + type = "button", + title = tooltip, + "aria-label"_attr = tooltip, + onClick = [&onRemove]() { + onRemove(); + }, + }, + .styleVariant = Snc::StyleVariant::Transparent, + }); + // clang-format on + } + + Nui::ElementRenderer groupActions(TrackingActions const& actions, OutlinerGroup group) + { + switch (group) + { + case OutlinerGroup::Stopped: + { + const auto tooltip = language->get("fileTracking", "removeAllStoppedTooltip"); + return groupTrash("remove-all-stopped", tooltip, actions.removeAllStopped); + } + case OutlinerGroup::Orphaned: + { + const auto tooltip = language->get("fileTracking", "deleteAllOrphansTooltip"); + return groupTrash("delete-all-orphans", tooltip, actions.deleteAllOrphanedCopies); + } + case OutlinerGroup::NeedsYou: + case OutlinerGroup::Syncing: + case OutlinerGroup::Watching: + break; + } + return Nui::nil(); + } + + Nui::ElementRenderer headerEnd( + TrackingActions const& actions, + OutlinerGroup group, + std::shared_ptr> const& count + ) + { + using namespace Nui::Attributes; + using Nui::Elements::span; + namespace Snc = ScriptNuiComponents; + + // clang-format off + return span{class_ = "file-tracking-group-header-end"}( + Snc::countBadge( + count, + Snc::StyleVariant::Transparent, + {"data-class"_attr = "file-tracking-group-count"} + ), + groupActions(actions, group) + ); + // clang-format on + } + + Nui::ElementRenderer groupRows(TrackingStore& store, TrackingActions const& actions, OutlinerGroup group) + { + using namespace Nui::Attributes; + using Nui::Elements::div; + + // clang-format off + if (group == OutlinerGroup::Orphaned) + { + return div{class_ = "file-tracking-group-rows"}( + Nui::range(store.orphanMembers()), + [&store, &actions](long long, std::shared_ptr const& item) -> Nui::ElementRenderer { + return orphanRow(store, actions, item); + } + ); + } + return div{class_ = "file-tracking-group-rows"}( + Nui::range(store.membersOf(group)), + [&store, &actions](long long, std::shared_ptr const& item) -> Nui::ElementRenderer { + return watchRow(store, actions, item); + } + ); + // clang-format on + } + + Nui::ElementRenderer outlinerGroup(TrackingStore& store, TrackingActions const& actions, OutlinerGroup group) + { + using namespace Nui::Attributes; + namespace Snc = ScriptNuiComponents; + + const auto count = store.countOf(group); + + // clang-format off + return Snc::collapsibleSection( + { + .title = titleOf(group), + .headerEnd = headerEnd(actions, group, count), + .initiallyExpanded = store.groupExpanded(group), + .onToggle = [&actions, group](bool expanded) { + actions.setGroupExpanded(group, expanded); + }, + .attributes = { + "data-ft-group"_attr = Utility::enumToString(group), + "data-hidden"_attr = Nui::observe(count).generate([count]() { + return count->value() == 0u; + }), + }, + }, + groupRows(store, actions, group) + ); + // clang-format on + } + + Nui::ElementRenderer filterField(TrackingStore& store, TrackingActions const& actions) + { + using namespace Nui::Attributes; + using Nui::Elements::label; + namespace Snc = ScriptNuiComponents; + + const auto placeholder = language->get("fileTracking", "filterPlaceholder"); + + // clang-format off + return label{class_ = "file-tracking-filter"}( + Ui5Icons::search(), + Snc::textInput({ + .value = store.filter(), + .attributes = { + "data-ft-filter"_attr = true, + placeHolder = placeholder, + "aria-label"_attr = placeholder, + "autocomplete"_attr = "off", + "spellcheck"_attr = "false", + onInput = [&actions](Nui::val event) { + actions.filter(event["target"]["value"].as()); + }, + }, + .dontUpdateValue = true, + }) + ); + // clang-format on + } + } + + Nui::ElementRenderer outliner(TrackingStore& store, TrackingActions const& actions) + { + using namespace Nui::Attributes; + using Nui::Elements::div; + + std::vector groups; + groups.reserve(outlinerGroupOrder.size()); + for (const auto group : outlinerGroupOrder) + groups.push_back(outlinerGroup(store, actions, group)); + + // clang-format off + return div{class_ = "file-tracking-outliner"}( + filterField(store, actions), + div{ + class_ = "file-tracking-outliner-list", + "role"_attr = "listbox", + "aria-label"_attr = language->get("fileTracking", "outlinerLabel"), + onKeyDown = [&actions](Nui::WebApi::KeyboardEvent event) { + moveSelectionByKey(actions, event); + }, + }( + Nui::range(std::move(groups)), + [](long long, Nui::ElementRenderer const& group) -> Nui::ElementRenderer { + return group; + } + ) + ); + // clang-format on } } From 39d2edf6d94c57b027560c4657bfa4948e2df3b3 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:21 +0200 Subject: [PATCH 056/114] Styled the file tracking outliner, its groups, rows and glyphs --- static/styles/file_tracking_outliner.css | 213 +++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/static/styles/file_tracking_outliner.css b/static/styles/file_tracking_outliner.css index e69de29b..ecb55247 100644 --- a/static/styles/file_tracking_outliner.css +++ b/static/styles/file_tracking_outliner.css @@ -0,0 +1,213 @@ +:is(.file-tracking-outliner, .file-tracking-glyph) { + --file-tracking-outliner-ok: color-mix(in srgb, var(--theme-color) 78%, var(--anti-brightness-mixin)); + --file-tracking-outliner-warning: color-mix( + in srgb, + var(--script-nui-components-toast-warning, #e0a02e) 78%, + var(--anti-brightness-mixin) + ); + --file-tracking-outliner-danger: color-mix( + in srgb, + var(--script-nui-components-toast-error, #d9534f) 78%, + var(--anti-brightness-mixin) + ); + --file-tracking-outliner-muted: var(--color-secondary); +} + +.file-tracking-glyph { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--file-tracking-outliner-muted); + + & > svg { + width: 13px; + height: 13px; + fill: currentColor; + } + + &[data-status="Watching"], + &[data-status="Syncing"] { + color: var(--file-tracking-outliner-ok); + } + + &[data-status="Conflicts"] { + color: var(--file-tracking-outliner-warning); + } + + &[data-status="Failed"] { + color: var(--file-tracking-outliner-danger); + } +} + +.file-tracking-outliner { + --script-nui-components-list-row-height: 26px; + --script-nui-components-list-row-padding: 0 4px 0 10px; + --script-nui-components-list-row-gap: 8px; + --script-nui-components-list-row-radius: 0; + + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background-color: color-mix(in srgb, var(--background-color) 88%, black); +} + +.file-tracking-filter { + position: relative; + display: flex; + flex: 0 0 auto; + align-items: center; + margin: 6px; + color: var(--color-secondary); + + & > svg { + position: absolute; + left: 8px; + width: 13px; + height: 13px; + fill: currentColor; + pointer-events: none; + } + + & > .script-nui-text-input { + flex: 1 1 auto; + width: 100%; + min-width: 0; + height: 26px; + padding: 0 8px 0 27px; + border-radius: 4px; + } +} + +.file-tracking-outliner-list { + flex: 1 1 auto; + min-height: 0; + padding-bottom: 6px; + overflow-x: hidden; + overflow-y: auto; + + & > .script-nui-collapsible { + --script-nui-components-collapsible-color: var(--color-secondary); + --script-nui-components-collapsible-muted-color: var(--color-secondary); + + &[data-hidden] { + display: none; + } + + & > .script-nui-collapsible-summary { + gap: 4px; + min-height: 24px; + padding: 0 4px 0 6px; + border-radius: 0; + font-size: 0.68rem; + letter-spacing: 0.07em; + text-transform: uppercase; + } + + & > .script-nui-collapsible-summary > .script-nui-collapsible-chevron { + width: 1.1em; + height: 1.1em; + } + + & > .script-nui-collapsible-summary > .script-nui-collapsible-title { + flex: 0 1 auto; + } + + & > .script-nui-collapsible-summary > .script-nui-collapsible-header-end { + flex: 1 1 auto; + min-width: 0; + } + + & > .script-nui-collapsible-body { + padding: 0; + } + } +} + +.file-tracking-group-header-end { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: 4px; + min-width: 0; + + & > .script-nui-badge[data-class="file-tracking-group-count"] { + min-width: 0; + padding: 0; + font-size: inherit; + letter-spacing: normal; + } + + & > .script-nui-button[data-class="file-tracking-trash"] { + margin-left: auto; + } +} + +.file-tracking-group-rows { + display: flex; + flex-direction: column; +} + +.file-tracking-outliner .script-nui-list-row { + & > .script-nui-list-row-leading { + width: 16px; + } + + & > .script-nui-list-row-trailing:has(> .file-tracking-row-actions:empty) { + display: none; + } + + &[data-ft-state="Conflicts"] > .script-nui-list-row-side { + color: var(--file-tracking-outliner-warning); + } + + &[data-ft-state="Failed"] > .script-nui-list-row-side { + color: var(--file-tracking-outliner-danger); + } +} + +.file-tracking-row-leading, +.file-tracking-row-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; +} + +.file-tracking-row-leading { + width: 16px; + height: 16px; +} + +.file-tracking-outliner .script-nui-button[data-class="file-tracking-trash"] { + flex: 0 0 auto; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + background-color: transparent; + color: var(--file-tracking-outliner-muted); + + & > svg { + width: 13px; + height: 13px; + } + + &:not(:disabled):hover { + border-color: var(--border-color-fields); + background-color: var(--background-color-fields); + color: var(--file-tracking-outliner-danger); + filter: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .file-tracking-outliner .script-nui-collapsible-chevron { + transition: none; + } +} From 37844de214dd82d3de53133faa220a62795ffef9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:38:42 +0200 Subject: [PATCH 057/114] Implemented the file tracking texts with offset-driven clock times and tests --- .../file_tracking/file_tracking_texts.hpp | 5 + .../file_tracking/file_tracking_texts.cpp | 268 +++++++++++-- frontend/test/frontend/main.cpp | 1 + .../frontend/test_file_tracking_texts.hpp | 369 ++++++++++++++++++ 4 files changed, 609 insertions(+), 34 deletions(-) create mode 100644 frontend/test/frontend/test_file_tracking_texts.hpp diff --git a/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp index 92e4fd56..1aa6b5ef 100644 --- a/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp +++ b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp @@ -151,6 +151,11 @@ namespace FileTracking */ std::string clockTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds); + /** + * @brief "YYYY-MM-DD HH:MM" of @p secondsSinceEpoch, a UTC time, shifted by @p utcOffsetSeconds. + */ + std::string dateTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds); + /** * @brief The offset of the local time zone from UTC at @p secondsSinceEpoch, in seconds. */ diff --git a/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp b/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp index 0f2e09b8..facd95cd 100644 --- a/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp +++ b/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp @@ -1,74 +1,274 @@ #include +#include + +#include + +#include +#include +#include +#include +#include +#include namespace FileTracking { - std::string conflictCountText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] std::uint64_t count) + namespace + { + constexpr std::int64_t secondsPerMinute = 60; + constexpr std::int64_t secondsPerHour = 3'600; + constexpr std::int64_t secondsPerDay = 86'400; + + /** + * @brief A day of the proleptic Gregorian calendar. + */ + struct CivilDate + { + std::int64_t year{1970}; + std::int64_t month{1}; + std::int64_t day{1}; + }; + + /** + * @brief @p value / @p divisor rounded towards negative infinity, @p divisor is positive. + */ + constexpr std::int64_t floorDivide(std::int64_t value, std::int64_t divisor) + { + const auto quotient = value / divisor; + return value % divisor != 0 && value < 0 ? quotient - 1 : quotient; + } + + /** + * @brief Days since 1970-01-01 of @p date (Howard Hinnant's days_from_civil). + */ + constexpr std::int64_t daysFromCivil(CivilDate const& date) + { + const auto year = date.month <= 2 ? date.year - 1 : date.year; + const auto era = floorDivide(year, 400); + const auto yearOfEra = year - era * 400; + const auto dayOfYear = (153 * (date.month > 2 ? date.month - 3 : date.month + 9) + 2) / 5 + date.day - 1; + const auto dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + return era * 146'097 + dayOfEra - 719'468; + } + + /** + * @brief The date @p days after 1970-01-01 (Howard Hinnant's civil_from_days). + */ + constexpr CivilDate civilFromDays(std::int64_t days) + { + const auto shifted = days + 719'468; + const auto era = floorDivide(shifted, 146'097); + const auto dayOfEra = shifted - era * 146'097; + const auto yearOfEra = (dayOfEra - dayOfEra / 1'460 + dayOfEra / 36'524 - dayOfEra / 146'096) / 365; + const auto dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100); + const auto monthIndex = (5 * dayOfYear + 2) / 153; + const auto month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9; + return CivilDate{ + .year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0), + .month = month, + .day = dayOfYear - (153 * monthIndex + 2) / 5 + 1, + }; + } + + static_assert(daysFromCivil(CivilDate{.year = 1970, .month = 1, .day = 1}) == 0); + static_assert(daysFromCivil(CivilDate{.year = 2000, .month = 3, .day = 1}) == 11'017); + static_assert(civilFromDays(-1).year == 1969 && civilFromDays(-1).month == 12 && civilFromDays(-1).day == 31); + static_assert(civilFromDays(11'016).month == 2 && civilFromDays(11'016).day == 29); + + /** + * @brief @p secondsSinceEpoch shifted by @p utcOffsetSeconds. + */ + std::int64_t shiftedSeconds(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds) + { + const auto limited = std::min( + secondsSinceEpoch, static_cast(std::numeric_limits::max() / 2) + ); + return static_cast(limited) + utcOffsetSeconds; + } + + /** + * @brief "HH:MM" of the second @p secondOfDay of a day. + */ + std::string hoursAndMinutes(std::int64_t secondOfDay) + { + return fmt::format( + "{:02}:{:02}", secondOfDay / secondsPerHour, secondOfDay % secondsPerHour / secondsPerMinute + ); + } + + /** + * @brief The local calendar time of @p time, nothing when the C library cannot tell. + */ + std::optional localTimeOf(std::time_t time) + { + std::tm local{}; +#ifdef _WIN32 + if (localtime_s(&local, &time) != 0) + return std::nullopt; +#else + if (localtime_r(&time, &local) == nullptr) + return std::nullopt; +#endif + return local; + } + + /** + * @brief @p time as local clock time, or @p unknownText when there is none. + */ + std::string clockTimeOr(std::optional const& time, std::string const& unknownText) + { + return time.has_value() ? localClockTime(*time) : unknownText; + } + } + + std::string conflictCountText(FileTrackingTexts const& texts, std::uint64_t count) + { + if (count == 1u) + return texts.counts.conflictOne; + return fmt::format(fmt::runtime(texts.counts.conflicts), count); + } + + std::string failedCountText(FileTrackingTexts const& texts, std::uint64_t count) { - return {}; + return fmt::format(fmt::runtime(texts.counts.failed), count); } - std::string failedCountText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] std::uint64_t count) + std::string stopSideText(FileTrackingTexts const& texts, SharedData::WatchStopReason reason) { - return {}; + switch (reason) + { + case SharedData::WatchStopReason::StoppedByUser: + return texts.stopSides.stoppedByUser; + case SharedData::WatchStopReason::Overlap: + return texts.stopSides.overlap; + case SharedData::WatchStopReason::CopyGone: + return texts.stopSides.copyGone; + case SharedData::WatchStopReason::ConnectionLost: + return texts.stopSides.connectionLost; + } + return texts.stopSides.stoppedByUser; } - std::string - stopSideText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] SharedData::WatchStopReason reason) + std::string stopReasonText(FileTrackingTexts const& texts, SharedData::WatchStop const& stop) { - return {}; + switch (stop.reason) + { + case SharedData::WatchStopReason::StoppedByUser: + return fmt::format(fmt::runtime(texts.stopReasons.stoppedByUser), localClockTime(stop.stoppedAt)); + case SharedData::WatchStopReason::Overlap: + if (stop.movedTo.has_value() && stop.otherWatchName.has_value()) + return fmt::format( + fmt::runtime(texts.stopReasons.overlap), stop.movedTo->toUtf8(), *stop.otherWatchName + ); + return texts.stopReasons.overlapUnknown; + case SharedData::WatchStopReason::CopyGone: + return texts.stopReasons.copyGone; + case SharedData::WatchStopReason::ConnectionLost: + return fmt::format(fmt::runtime(texts.stopReasons.connectionLost), localClockTime(stop.stoppedAt)); + } + return fmt::format(fmt::runtime(texts.stopReasons.stoppedByUser), localClockTime(stop.stoppedAt)); } - std::string - stopReasonText([[maybe_unused]] FileTrackingTexts const& texts, [[maybe_unused]] SharedData::WatchStop const& stop) + std::string failureReasonText(FileTrackingTexts const& texts, SharedData::UploadFailureReason reason) { - return {}; + auto const& reasons = texts.failureReasons; + switch (reason) + { + case SharedData::UploadFailureReason::LockedLocally: + return reasons.lockedLocally; + case SharedData::UploadFailureReason::PermissionDeniedLocally: + return reasons.permissionDeniedLocally; + case SharedData::UploadFailureReason::UnreadableLocally: + return reasons.unreadableLocally; + case SharedData::UploadFailureReason::PermissionDeniedOnServer: + return reasons.permissionDeniedOnServer; + case SharedData::UploadFailureReason::NoSpaceOnServer: + return reasons.noSpaceOnServer; + case SharedData::UploadFailureReason::ServerRefused: + return reasons.serverRefused; + case SharedData::UploadFailureReason::ConnectionProblem: + return reasons.connectionProblem; + case SharedData::UploadFailureReason::NotQueued: + return reasons.notQueued; + case SharedData::UploadFailureReason::Unknown: + return reasons.unknown; + } + return reasons.unknown; } - std::string failureReasonText( - [[maybe_unused]] FileTrackingTexts const& texts, - [[maybe_unused]] SharedData::UploadFailureReason reason - ) + std::string conflictLineText(FileTrackingTexts const& texts, SharedData::WatchConflict const& conflict) { - return {}; + auto const& lines = texts.conflictLines; + const auto serverTime = clockTimeOr(conflict.serverModifiedAt, lines.timeUnknown); + const auto localTime = clockTimeOr(conflict.localModifiedAt, lines.timeUnknown); + switch (conflict.kind) + { + case SharedData::ConflictKind::ChangedOnServer: + return fmt::format(fmt::runtime(lines.changedOnServer), serverTime, localTime); + case SharedData::ConflictKind::DeletedOnServer: + return fmt::format(fmt::runtime(lines.deletedOnServer), localTime); + case SharedData::ConflictKind::CreatedOnServer: + return fmt::format(fmt::runtime(lines.createdOnServer), serverTime, localTime); + } + return fmt::format(fmt::runtime(lines.changedOnServer), serverTime, localTime); } - std::string conflictLineText( - [[maybe_unused]] FileTrackingTexts const& texts, - [[maybe_unused]] SharedData::WatchConflict const& conflict - ) + std::string clockTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds) { - return {}; + const auto local = shiftedSeconds(secondsSinceEpoch, utcOffsetSeconds); + return hoursAndMinutes(local - floorDivide(local, secondsPerDay) * secondsPerDay); } - std::string - clockTime([[maybe_unused]] std::uint64_t secondsSinceEpoch, [[maybe_unused]] std::int64_t utcOffsetSeconds) + std::string dateTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds) { - return {}; + const auto local = shiftedSeconds(secondsSinceEpoch, utcOffsetSeconds); + const auto days = floorDivide(local, secondsPerDay); + const auto date = civilFromDays(days); + return fmt::format( + "{:04}-{:02}-{:02} {}", date.year, date.month, date.day, hoursAndMinutes(local - days * secondsPerDay) + ); } - std::int64_t localUtcOffset([[maybe_unused]] std::uint64_t secondsSinceEpoch) + std::int64_t localUtcOffset(std::uint64_t secondsSinceEpoch) { - return 0; + const auto time = static_cast(shiftedSeconds(secondsSinceEpoch, 0)); + const auto local = localTimeOf(time); + if (!local.has_value()) + return 0; + const auto days = daysFromCivil( + CivilDate{ + .year = std::int64_t{local->tm_year} + 1'900, + .month = std::int64_t{local->tm_mon} + 1, + .day = std::int64_t{local->tm_mday}, + } + ); + const auto localSeconds = days * secondsPerDay + std::int64_t{local->tm_hour} * secondsPerHour + + std::int64_t{local->tm_min} * secondsPerMinute + std::int64_t{local->tm_sec}; + return localSeconds - static_cast(time); } - std::string localClockTime([[maybe_unused]] std::uint64_t secondsSinceEpoch) + std::string localClockTime(std::uint64_t secondsSinceEpoch) { - return {}; + return clockTime(secondsSinceEpoch, localUtcOffset(secondsSinceEpoch)); } - std::string localDateTime([[maybe_unused]] std::uint64_t secondsSinceEpoch) + std::string localDateTime(std::uint64_t secondsSinceEpoch) { - return {}; + return dateTime(secondsSinceEpoch, localUtcOffset(secondsSinceEpoch)); } - std::string sizeText([[maybe_unused]] std::uint64_t bytes) + std::string sizeText(std::uint64_t bytes) { - return {}; + const auto limited = + std::min(bytes, static_cast(std::numeric_limits::max())); + return Utility::formatBytes(static_cast(limited)); } - std::string - pathWithin([[maybe_unused]] Paths::LocalPath const& watched, [[maybe_unused]] Paths::LocalPath const& path) + std::string pathWithin(Paths::LocalPath const& watched, Paths::LocalPath const& path) { - return {}; + const auto relative = path.relativeTo(watched); + if (relative.has_value() && !relative->empty()) + return relative->value(); + const auto name = path.filename(); + return name.has_value() ? name->value() : path.toUtf8(); } } diff --git a/frontend/test/frontend/main.cpp b/frontend/test/frontend/main.cpp index a0526819..d886b323 100644 --- a/frontend/test/frontend/main.cpp +++ b/frontend/test/frontend/main.cpp @@ -1,5 +1,6 @@ #include "test_attention_state.hpp" #include "test_drop_text.hpp" +#include "test_file_tracking_texts.hpp" #include "test_in_flight_rows.hpp" #include "test_item_names.hpp" #include "test_item_order.hpp" diff --git a/frontend/test/frontend/test_file_tracking_texts.hpp b/frontend/test/frontend/test_file_tracking_texts.hpp new file mode 100644 index 00000000..86e8695d --- /dev/null +++ b/frontend/test/frontend/test_file_tracking_texts.hpp @@ -0,0 +1,369 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking::Test +{ + namespace FileTrackingTextsTestDetail + { + constexpr std::uint64_t serverTime = 1'700'000'000u; + constexpr std::uint64_t localTime = 1'700'003'600u; + + /** + * @brief Every text filled, as the English language file has them. + */ + inline FileTrackingTexts sampleTexts() + { + FileTrackingTexts texts{}; + texts.counts = {.conflictOne = "1 conflict", .conflicts = "{} conflicts", .failed = "{} failed"}; + texts.stopSides = { + .stoppedByUser = "stopped", + .overlap = "overlap", + .copyGone = "deleted", + .connectionLost = "connection lost", + }; + texts.statuses = {.syncing = "Syncing", .upToDate = "Up to date", .stopped = "Stopped"}; + texts.stopReasons = { + .stoppedByUser = "You stopped the watch at {}.", + .overlap = "Moved into {}, which the watch '{}' covers.", + .overlapUnknown = "Moved onto the server path of another watch.", + .copyGone = "The copy was deleted or renamed.", + .connectionLost = "The connection was lost at {}, the watch stopped.", + }; + texts.failureReasons = { + .lockedLocally = "locked locally", + .permissionDeniedLocally = "permission denied locally", + .unreadableLocally = "unreadable locally", + .permissionDeniedOnServer = "permission denied on the server", + .noSpaceOnServer = "no space on the server", + .serverRefused = "server refused", + .connectionProblem = "connection problem", + .notQueued = "not queued", + .unknown = "unknown", + }; + texts.conflictLines = { + .changedOnServer = "Changed on the server at {}, saved here at {}", + .deletedOnServer = "Deleted on the server, saved here at {}", + .createdOnServer = "Created on the server at {}, saved here at {}", + .timeUnknown = "--:--", + }; + return texts; + } + + inline Paths::LocalPath localPath(std::string_view path) + { + return *Paths::LocalPath::fromUtf8(path, Paths::LocalPlatform::Posix); + } + + inline SharedData::WatchStop stopOf(SharedData::WatchStopReason reason) + { + return SharedData::WatchStop{.reason = reason, .stoppedAt = serverTime}; + } + + inline SharedData::WatchConflict conflictOf( + SharedData::ConflictKind kind, + std::optional serverModifiedAt, + std::optional localModifiedAt + ) + { + SharedData::WatchConflict conflict{}; + conflict.conflictId = Ids::makeConflictId("conflict"); + conflict.watchId = Ids::makeWatchId("watch"); + conflict.kind = kind; + conflict.localPath = localPath("/home/user/notes.txt"); + conflict.remotePath = *Paths::RemotePath::make("/srv/notes.txt"); + conflict.serverModifiedAt = serverModifiedAt; + conflict.localModifiedAt = localModifiedAt; + conflict.raisedAt = localTime; + return conflict; + } + } + + TEST(FileTrackingTextsTests, ConflictCountsUseTheSingularForOne) + { + const auto texts = FileTrackingTextsTestDetail::sampleTexts(); + + EXPECT_EQ(conflictCountText(texts, 0u), "0 conflicts"); + EXPECT_EQ(conflictCountText(texts, 1u), "1 conflict"); + EXPECT_EQ(conflictCountText(texts, 2u), "2 conflicts"); + EXPECT_EQ(conflictCountText(texts, 1'234u), "1234 conflicts"); + } + + TEST(FileTrackingTextsTests, FailedCountsUseOneTextForEveryCount) + { + const auto texts = FileTrackingTextsTestDetail::sampleTexts(); + + EXPECT_EQ(failedCountText(texts, 1u), "1 failed"); + EXPECT_EQ(failedCountText(texts, 5u), "5 failed"); + } + + TEST(FileTrackingTextsTests, EveryStopReasonHasItsSideText) + { + const auto texts = FileTrackingTextsTestDetail::sampleTexts(); + + EXPECT_EQ(stopSideText(texts, SharedData::WatchStopReason::StoppedByUser), "stopped"); + EXPECT_EQ(stopSideText(texts, SharedData::WatchStopReason::Overlap), "overlap"); + EXPECT_EQ(stopSideText(texts, SharedData::WatchStopReason::CopyGone), "deleted"); + EXPECT_EQ(stopSideText(texts, SharedData::WatchStopReason::ConnectionLost), "connection lost"); + } + + TEST(FileTrackingTextsTests, AStopByTheUserTellsItsLocalTime) + { + using namespace FileTrackingTextsTestDetail; + const auto texts = sampleTexts(); + + EXPECT_EQ( + stopReasonText(texts, stopOf(SharedData::WatchStopReason::StoppedByUser)), + fmt::format("You stopped the watch at {}.", localClockTime(serverTime)) + ); + } + + TEST(FileTrackingTextsTests, ALostConnectionTellsItsLocalTime) + { + using namespace FileTrackingTextsTestDetail; + const auto texts = sampleTexts(); + + EXPECT_EQ( + stopReasonText(texts, stopOf(SharedData::WatchStopReason::ConnectionLost)), + fmt::format("The connection was lost at {}, the watch stopped.", localClockTime(serverTime)) + ); + } + + TEST(FileTrackingTextsTests, AGoneCopyNeedsNoDetails) + { + using namespace FileTrackingTextsTestDetail; + + EXPECT_EQ( + stopReasonText(sampleTexts(), stopOf(SharedData::WatchStopReason::CopyGone)), + "The copy was deleted or renamed." + ); + } + + TEST(FileTrackingTextsTests, AnOverlapNamesWhereTheItemMovedAndTheOtherWatch) + { + using namespace FileTrackingTextsTestDetail; + auto stop = stopOf(SharedData::WatchStopReason::Overlap); + stop.otherWatchId = Ids::makeWatchId("other"); + stop.otherWatchName = "project"; + stop.movedTo = localPath("/home/user/project/notes.txt"); + + EXPECT_EQ( + stopReasonText(sampleTexts(), stop), + "Moved into /home/user/project/notes.txt, which the watch 'project' covers." + ); + } + + TEST(FileTrackingTextsTests, AnOverlapWithoutItsDetailsFallsBackToTheGeneralSentence) + { + using namespace FileTrackingTextsTestDetail; + const auto texts = sampleTexts(); + const auto fallback = std::string{"Moved onto the server path of another watch."}; + + auto withoutAnything = stopOf(SharedData::WatchStopReason::Overlap); + withoutAnything.otherWatchId = Ids::makeWatchId("other"); + EXPECT_EQ(stopReasonText(texts, withoutAnything), fallback); + + auto withoutName = stopOf(SharedData::WatchStopReason::Overlap); + withoutName.movedTo = localPath("/home/user/project/notes.txt"); + EXPECT_EQ(stopReasonText(texts, withoutName), fallback); + + auto withoutTarget = stopOf(SharedData::WatchStopReason::Overlap); + withoutTarget.otherWatchName = "project"; + EXPECT_EQ(stopReasonText(texts, withoutTarget), fallback); + } + + TEST(FileTrackingTextsTests, EveryFailureReasonHasItsSentence) + { + const auto texts = FileTrackingTextsTestDetail::sampleTexts(); + const std::vector> expected{ + {SharedData::UploadFailureReason::LockedLocally, "locked locally"}, + {SharedData::UploadFailureReason::PermissionDeniedLocally, "permission denied locally"}, + {SharedData::UploadFailureReason::UnreadableLocally, "unreadable locally"}, + {SharedData::UploadFailureReason::PermissionDeniedOnServer, "permission denied on the server"}, + {SharedData::UploadFailureReason::NoSpaceOnServer, "no space on the server"}, + {SharedData::UploadFailureReason::ServerRefused, "server refused"}, + {SharedData::UploadFailureReason::ConnectionProblem, "connection problem"}, + {SharedData::UploadFailureReason::NotQueued, "not queued"}, + {SharedData::UploadFailureReason::Unknown, "unknown"}, + }; + + for (auto const& [reason, sentence] : expected) + EXPECT_EQ(failureReasonText(texts, reason), sentence); + } + + TEST(FileTrackingTextsTests, AServerChangeTellsBothLocalTimes) + { + using namespace FileTrackingTextsTestDetail; + + EXPECT_EQ( + conflictLineText( + sampleTexts(), conflictOf(SharedData::ConflictKind::ChangedOnServer, serverTime, localTime) + ), + fmt::format( + "Changed on the server at {}, saved here at {}", localClockTime(serverTime), localClockTime(localTime) + ) + ); + } + + TEST(FileTrackingTextsTests, AServerDeletionTellsTheLocalSave) + { + using namespace FileTrackingTextsTestDetail; + + EXPECT_EQ( + conflictLineText( + sampleTexts(), conflictOf(SharedData::ConflictKind::DeletedOnServer, std::nullopt, localTime) + ), + fmt::format("Deleted on the server, saved here at {}", localClockTime(localTime)) + ); + } + + TEST(FileTrackingTextsTests, AServerCreationTellsBothLocalTimes) + { + using namespace FileTrackingTextsTestDetail; + + EXPECT_EQ( + conflictLineText( + sampleTexts(), conflictOf(SharedData::ConflictKind::CreatedOnServer, serverTime, localTime) + ), + fmt::format( + "Created on the server at {}, saved here at {}", localClockTime(serverTime), localClockTime(localTime) + ) + ); + } + + TEST(FileTrackingTextsTests, UnknownTimesShowThePlaceholder) + { + using namespace FileTrackingTextsTestDetail; + const auto texts = sampleTexts(); + + EXPECT_EQ( + conflictLineText(texts, conflictOf(SharedData::ConflictKind::ChangedOnServer, std::nullopt, std::nullopt)), + "Changed on the server at --:--, saved here at --:--" + ); + EXPECT_EQ( + conflictLineText(texts, conflictOf(SharedData::ConflictKind::ChangedOnServer, serverTime, std::nullopt)), + fmt::format("Changed on the server at {}, saved here at --:--", localClockTime(serverTime)) + ); + EXPECT_EQ( + conflictLineText(texts, conflictOf(SharedData::ConflictKind::DeletedOnServer, serverTime, std::nullopt)), + "Deleted on the server, saved here at --:--" + ); + EXPECT_EQ( + conflictLineText(texts, conflictOf(SharedData::ConflictKind::CreatedOnServer, std::nullopt, localTime)), + fmt::format("Created on the server at --:--, saved here at {}", localClockTime(localTime)) + ); + } + + TEST(FileTrackingTextsTests, ClockTimeInUtc) + { + EXPECT_EQ(clockTime(0u, 0), "00:00"); + EXPECT_EQ(clockTime(59u, 0), "00:00"); + EXPECT_EQ(clockTime(60u, 0), "00:01"); + EXPECT_EQ(clockTime(1'700'000'000u, 0), "22:13"); + } + + TEST(FileTrackingTextsTests, ClockTimeWithAPositiveOffset) + { + EXPECT_EQ(clockTime(1'700'000'000u, 3'600), "23:13"); + EXPECT_EQ(clockTime(1'700'000'000u, 7'200), "00:13"); + EXPECT_EQ(clockTime(1'700'000'000u, 19'800), "03:43"); + EXPECT_EQ(clockTime(1'700'000'000u, 20'700), "03:58"); + } + + TEST(FileTrackingTextsTests, ClockTimeWithANegativeOffset) + { + EXPECT_EQ(clockTime(1'700'000'000u, -18'000), "17:13"); + EXPECT_EQ(clockTime(1'700'000'000u, -80'000), "00:00"); + EXPECT_EQ(clockTime(1'700'000'000u, -80'001), "23:59"); + EXPECT_EQ(clockTime(1'699'920'600u, -3'600), "23:10"); + } + + TEST(FileTrackingTextsTests, ClockTimeAroundMidnight) + { + EXPECT_EQ(clockTime(86'399u, 0), "23:59"); + EXPECT_EQ(clockTime(86'400u, 0), "00:00"); + EXPECT_EQ(clockTime(86'340u, 59), "23:59"); + EXPECT_EQ(clockTime(86'340u, 60), "00:00"); + EXPECT_EQ(clockTime(0u, -3'600), "23:00"); + EXPECT_EQ(clockTime(59u, -60), "23:59"); + } + + TEST(FileTrackingTextsTests, DateTimeWithFixedOffsets) + { + EXPECT_EQ(dateTime(0u, 0), "1970-01-01 00:00"); + EXPECT_EQ(dateTime(0u, -3'600), "1969-12-31 23:00"); + EXPECT_EQ(dateTime(1'700'000'000u, 0), "2023-11-14 22:13"); + EXPECT_EQ(dateTime(1'700'000'000u, 7'200), "2023-11-15 00:13"); + EXPECT_EQ(dateTime(1'700'000'000u, -80'001), "2023-11-13 23:59"); + EXPECT_EQ(dateTime(951'827'400u, 0), "2000-02-29 12:30"); + EXPECT_EQ(dateTime(951'827'400u, 43'200), "2000-03-01 00:30"); + EXPECT_EQ(dateTime(1'735'689'540u, 0), "2024-12-31 23:59"); + EXPECT_EQ(dateTime(1'735'689'540u, 60), "2025-01-01 00:00"); + } + + TEST(FileTrackingTextsTests, LocalTimesUseTheLocalOffset) + { + using namespace FileTrackingTextsTestDetail; + const auto offset = localUtcOffset(serverTime); + const auto day = std::int64_t{86'400}; + + EXPECT_GT(offset, -day); + EXPECT_LT(offset, day); + EXPECT_EQ(localClockTime(serverTime), clockTime(serverTime, offset)); + EXPECT_EQ(localDateTime(serverTime), dateTime(serverTime, offset)); + EXPECT_EQ(localClockTime(serverTime).size(), std::size_t{5}); + EXPECT_EQ(localDateTime(serverTime).size(), std::size_t{16}); + } + + TEST(FileTrackingTextsTests, SizesAreWrittenAsFormatBytesWritesThem) + { + EXPECT_EQ(sizeText(0u), Utility::formatBytes(0)); + EXPECT_EQ(sizeText(512u), Utility::formatBytes(512)); + EXPECT_EQ(sizeText(1'536u), "1.50 KB"); + EXPECT_EQ(sizeText(5'242'880u), "5.00 MB"); + EXPECT_EQ( + sizeText(std::numeric_limits::max()), + Utility::formatBytes(std::numeric_limits::max()) + ); + } + + TEST(FileTrackingTextsTests, APathBelowTheWatchedDirectoryIsShownRelative) + { + using namespace FileTrackingTextsTestDetail; + const auto watched = localPath("/home/user/project"); + + EXPECT_EQ(pathWithin(watched, localPath("/home/user/project/notes.txt")), "notes.txt"); + EXPECT_EQ(pathWithin(watched, localPath("/home/user/project/source/main.cpp")), "source/main.cpp"); + } + + TEST(FileTrackingTextsTests, TheWatchedItemItselfIsShownByItsName) + { + using namespace FileTrackingTextsTestDetail; + + EXPECT_EQ(pathWithin(localPath("/home/user/notes.txt"), localPath("/home/user/notes.txt")), "notes.txt"); + EXPECT_EQ(pathWithin(localPath("/home/user/project"), localPath("/home/user/project")), "project"); + } + + TEST(FileTrackingTextsTests, APathOutsideTheWatchedItemIsShownByItsName) + { + using namespace FileTrackingTextsTestDetail; + const auto watched = localPath("/home/user/project"); + + EXPECT_EQ(pathWithin(watched, localPath("/home/user/other/notes.txt")), "notes.txt"); + EXPECT_EQ(pathWithin(watched, localPath("/home/user/project-old/notes.txt")), "notes.txt"); + EXPECT_EQ(pathWithin(watched, localPath("/")), "/"); + } +} From 0709aa63f05c1dc2fdd7bc21dee689c56c59c73f Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:38:42 +0200 Subject: [PATCH 058/114] Implemented the file tracking model's groups, order and counts with tests --- .../file_tracking/tracking_model.hpp | 11 +- .../file_tracking/tracking_model.cpp | 370 ++++++-- .../test/frontend/test_tracking_model.hpp | 855 ++++++++++++++++++ 3 files changed, 1175 insertions(+), 61 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp index 0d6ecc7d..205ef665 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp @@ -102,12 +102,13 @@ namespace FileTracking std::optional watchOfFailure(Ids::FailureId const& failureId) const; /** - * @return The conflicts of @p watchId, newest raisedAt first, ties by conflict id. + * @return The conflicts of @p watchId, newest raisedAt first, ties by the greater conflict id first like + * @ref newestConflict. */ std::vector conflictsOf(Ids::WatchId const& watchId) const; /** - * @return The failures of @p watchId, newest failedAt first, ties by failure id. + * @return The failures of @p watchId, newest failedAt first, ties by the greater failure id first. */ std::vector failuresOf(Ids::WatchId const& watchId) const; @@ -122,12 +123,14 @@ namespace FileTracking std::uint64_t failureCountOf(Ids::WatchId const& watchId) const; /** - * @return The conflicts of every known watch, for the dock tab. + * @return The conflicts of every known watch, for the dock tab. Conflicts of a watch not known yet are left + * out. */ std::uint64_t conflictCount() const; /** - * @return The failures of every known watch, for the dock tab. + * @return The failures of every known watch, for the dock tab. Failures of a watch not known yet are left + * out. */ std::uint64_t failureCount() const; diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp index 073494ab..05128de6 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp @@ -1,134 +1,390 @@ #include +#include +#include +#include +#include + namespace FileTracking { - bool matchesFilter([[maybe_unused]] std::string_view name, [[maybe_unused]] std::string_view filter) + namespace { - return true; + char foldedCase(char character) + { + return character >= 'A' && character <= 'Z' ? static_cast(character - 'A' + 'a') : character; + } + + std::string foldedName(std::string_view name) + { + std::string folded(name.size(), '\0'); + std::ranges::transform(name, folded.begin(), foldedCase); + return folded; + } + + /** + * @brief A member of an outliner group with what it is sorted by. + */ + template + struct NamedMember + { + std::string foldedName; + IdT id; + }; + + template + std::vector inNameOrder(std::vector> members) + { + std::ranges::sort( + members, + [](NamedMember const& left, NamedMember const& right) + { + if (left.foldedName != right.foldedName) + return left.foldedName < right.foldedName; + return left.id < right.id; + } + ); + std::vector result; + result.reserve(members.size()); + std::ranges::transform( + members, + std::back_inserter(result), + [](NamedMember const& member) + { + return Selection{std::in_place_type, member.id}; + } + ); + return result; + } + + bool isNewer(SharedData::WatchConflict const& conflict, SharedData::WatchConflict const& other) + { + if (conflict.raisedAt != other.raisedAt) + return conflict.raisedAt > other.raisedAt; + return conflict.conflictId > other.conflictId; + } + + bool isNewer(SharedData::UploadFailure const& failure, SharedData::UploadFailure const& other) + { + if (failure.failedAt != other.failedAt) + return failure.failedAt > other.failedAt; + return failure.failureId > other.failureId; + } + + template + std::vector newestFirstOf(MapT const& items, Ids::WatchId const& watchId) + { + std::vector result; + for (auto const& item : items | std::views::values) + { + if (item.watchId == watchId) + result.push_back(item); + } + std::ranges::sort( + result, + [](auto const& left, auto const& right) + { + return isNewer(left, right); + } + ); + return result; + } + + template + std::uint64_t countOf(MapT const& items, PredicateT predicate) + { + return static_cast(std::ranges::count_if(items | std::views::values, predicate)); + } + + template + std::optional watchOf(MapT const& items, IdT const& itemId) + { + const auto found = items.find(itemId); + if (found == items.end()) + return std::nullopt; + return found->second.watchId; + } + + bool isHeldByAnEntry( + std::map const& entries, + Ids::InstanceId const& instanceId + ) + { + return std::ranges::any_of( + entries | std::views::values, + [&instanceId](SharedData::TrackedEntry const& entry) + { + return entry.instanceId == instanceId; + } + ); + } } - void TrackingModel::showEntry([[maybe_unused]] SharedData::TrackedEntry const& entry) - {} + bool matchesFilter(std::string_view name, std::string_view filter) + { + const auto equalIgnoringCase = [](char left, char right) + { + return foldedCase(left) == foldedCase(right); + }; + return filter.empty() || !std::ranges::search(name, filter, equalIgnoringCase).empty(); + } - void TrackingModel::removeEntry([[maybe_unused]] Ids::WatchId const& watchId) - {} + void TrackingModel::showEntry(SharedData::TrackedEntry const& entry) + { + entries_.insert_or_assign(entry.watchId, entry); + } - void TrackingModel::showConflict([[maybe_unused]] SharedData::WatchConflict const& conflict) - {} + void TrackingModel::removeEntry(Ids::WatchId const& watchId) + { + const auto removed = watchId; + const auto ofRemoved = [&removed](auto const& item) + { + return item.second.watchId == removed; + }; + entries_.erase(removed); + progress_.erase(removed); + std::erase_if(conflicts_, ofRemoved); + std::erase_if(failures_, ofRemoved); + } - void TrackingModel::removeConflict([[maybe_unused]] Ids::ConflictId const& conflictId) - {} + void TrackingModel::showConflict(SharedData::WatchConflict const& conflict) + { + conflicts_.insert_or_assign(conflict.conflictId, conflict); + } - void TrackingModel::showFailure([[maybe_unused]] SharedData::UploadFailure const& failure) - {} + void TrackingModel::removeConflict(Ids::ConflictId const& conflictId) + { + conflicts_.erase(conflictId); + } - void TrackingModel::removeFailure([[maybe_unused]] Ids::FailureId const& failureId) - {} + void TrackingModel::showFailure(SharedData::UploadFailure const& failure) + { + failures_.insert_or_assign(failure.failureId, failure); + } - void TrackingModel::showWatchProgress([[maybe_unused]] SharedData::WatchProgress const& progress) - {} + void TrackingModel::removeFailure(Ids::FailureId const& failureId) + { + failures_.erase(failureId); + } - void TrackingModel::showOrphanedCopies([[maybe_unused]] SharedData::OrphanedCopies const& copies) - {} + void TrackingModel::showWatchProgress(SharedData::WatchProgress const& progress) + { + progress_.insert_or_assign(progress.watchId, progress); + } + + void TrackingModel::showOrphanedCopies(SharedData::OrphanedCopies const& copies) + { + orphans_ = copies.copies; + } void TrackingModel::clear() - {} + { + entries_.clear(); + progress_.clear(); + conflicts_.clear(); + failures_.clear(); + orphans_.clear(); + } - SharedData::TrackedEntry const* TrackingModel::entryOf([[maybe_unused]] Ids::WatchId const& watchId) const + SharedData::TrackedEntry const* TrackingModel::entryOf(Ids::WatchId const& watchId) const { - return nullptr; + const auto found = entries_.find(watchId); + return found == entries_.end() ? nullptr : &found->second; } - SharedData::WatchProgress const* TrackingModel::progressOf([[maybe_unused]] Ids::WatchId const& watchId) const + SharedData::WatchProgress const* TrackingModel::progressOf(Ids::WatchId const& watchId) const { - return nullptr; + const auto found = progress_.find(watchId); + return found == progress_.end() ? nullptr : &found->second; } - SharedData::OrphanedCopy const* TrackingModel::orphanOf([[maybe_unused]] Ids::InstanceId const& instanceId) const + SharedData::OrphanedCopy const* TrackingModel::orphanOf(Ids::InstanceId const& instanceId) const { - return nullptr; + if (isHeldByAnEntry(entries_, instanceId)) + return nullptr; + const auto found = std::ranges::find_if( + orphans_, + [&instanceId](SharedData::OrphanedCopy const& copy) + { + return copy.instanceId == instanceId; + } + ); + return found == orphans_.end() ? nullptr : &*found; } - std::optional TrackingModel::watchOfConflict([[maybe_unused]] Ids::ConflictId const& conflictId) const + std::optional TrackingModel::watchOfConflict(Ids::ConflictId const& conflictId) const { - return std::nullopt; + return watchOf(conflicts_, conflictId); } - std::optional TrackingModel::watchOfFailure([[maybe_unused]] Ids::FailureId const& failureId) const + std::optional TrackingModel::watchOfFailure(Ids::FailureId const& failureId) const { - return std::nullopt; + return watchOf(failures_, failureId); } - std::vector - TrackingModel::conflictsOf([[maybe_unused]] Ids::WatchId const& watchId) const + std::vector TrackingModel::conflictsOf(Ids::WatchId const& watchId) const { - return {}; + return newestFirstOf(conflicts_, watchId); } - std::vector TrackingModel::failuresOf([[maybe_unused]] Ids::WatchId const& watchId) const + std::vector TrackingModel::failuresOf(Ids::WatchId const& watchId) const { - return {}; + return newestFirstOf(failures_, watchId); } - std::uint64_t TrackingModel::conflictCountOf([[maybe_unused]] Ids::WatchId const& watchId) const + std::uint64_t TrackingModel::conflictCountOf(Ids::WatchId const& watchId) const { - return 0u; + return countOf( + conflicts_, + [&watchId](SharedData::WatchConflict const& conflict) + { + return conflict.watchId == watchId; + } + ); } - std::uint64_t TrackingModel::failureCountOf([[maybe_unused]] Ids::WatchId const& watchId) const + std::uint64_t TrackingModel::failureCountOf(Ids::WatchId const& watchId) const { - return 0u; + return countOf( + failures_, + [&watchId](SharedData::UploadFailure const& failure) + { + return failure.watchId == watchId; + } + ); } std::uint64_t TrackingModel::conflictCount() const { - return 0u; + return countOf( + conflicts_, + [this](SharedData::WatchConflict const& conflict) + { + return entries_.contains(conflict.watchId); + } + ); } std::uint64_t TrackingModel::failureCount() const { - return 0u; + return countOf( + failures_, + [this](SharedData::UploadFailure const& failure) + { + return entries_.contains(failure.watchId); + } + ); } - WatchStatus TrackingModel::statusOf([[maybe_unused]] Ids::WatchId const& watchId) const + WatchStatus TrackingModel::statusOf(Ids::WatchId const& watchId) const { + auto const* entry = entryOf(watchId); + if (entry == nullptr) + return WatchStatus::Watching; + if (entry->stop.has_value()) + return WatchStatus::Stopped; + if (conflictCountOf(watchId) > 0u) + return WatchStatus::Conflicts; + if (failureCountOf(watchId) > 0u) + return WatchStatus::Failed; + auto const* progress = progressOf(watchId); + if (entry->uploading || (progress != nullptr && progress->changesPending > 0u)) + return WatchStatus::Syncing; return WatchStatus::Watching; } - OutlinerGroup TrackingModel::groupOf([[maybe_unused]] Ids::WatchId const& watchId) const + OutlinerGroup TrackingModel::groupOf(Ids::WatchId const& watchId) const { + switch (statusOf(watchId)) + { + case WatchStatus::Stopped: + return OutlinerGroup::Stopped; + case WatchStatus::Conflicts: + case WatchStatus::Failed: + return OutlinerGroup::NeedsYou; + case WatchStatus::Syncing: + return OutlinerGroup::Syncing; + case WatchStatus::Watching: + return OutlinerGroup::Watching; + } return OutlinerGroup::Watching; } - std::vector - TrackingModel::members([[maybe_unused]] OutlinerGroup group, [[maybe_unused]] std::string_view filter) const + std::vector TrackingModel::members(OutlinerGroup group, std::string_view filter) const { - return {}; + if (group == OutlinerGroup::Orphaned) + { + std::vector> copies; + for (auto const& copy : orphans_) + { + if (!isHeldByAnEntry(entries_, copy.instanceId) && matchesFilter(copy.name, filter)) + copies.push_back({.foldedName = foldedName(copy.name), .id = copy.instanceId}); + } + return inNameOrder(std::move(copies)); + } + std::vector> watches; + for (auto const& [watchId, entry] : entries_) + { + if (groupOf(watchId) == group && matchesFilter(entry.name, filter)) + watches.push_back({.foldedName = foldedName(entry.name), .id = watchId}); + } + return inNameOrder(std::move(watches)); } - std::string TrackingModel::sideTextOf( - [[maybe_unused]] Ids::WatchId const& watchId, - [[maybe_unused]] FileTrackingTexts const& texts - ) const + std::string TrackingModel::sideTextOf(Ids::WatchId const& watchId, FileTrackingTexts const& texts) const { + const auto conflicts = conflictCountOf(watchId); + if (conflicts > 0u) + return conflictCountText(texts, conflicts); + const auto failures = failureCountOf(watchId); + if (failures > 0u) + return failedCountText(texts, failures); + auto const* entry = entryOf(watchId); + if (entry != nullptr && entry->stop.has_value()) + return stopSideText(texts, entry->stop->reason); return {}; } - std::string TrackingModel::statusTextOf( - [[maybe_unused]] Ids::WatchId const& watchId, - [[maybe_unused]] FileTrackingTexts const& texts - ) const + std::string TrackingModel::statusTextOf(Ids::WatchId const& watchId, FileTrackingTexts const& texts) const { - return {}; + switch (statusOf(watchId)) + { + case WatchStatus::Conflicts: + return conflictCountText(texts, conflictCountOf(watchId)); + case WatchStatus::Failed: + return failedCountText(texts, failureCountOf(watchId)); + case WatchStatus::Syncing: + return texts.statuses.syncing; + case WatchStatus::Stopped: + return texts.statuses.stopped; + case WatchStatus::Watching: + return texts.statuses.upToDate; + } + return texts.statuses.upToDate; } - double TrackingModel::ringFractionOf([[maybe_unused]] Ids::WatchId const& watchId) const + double TrackingModel::ringFractionOf(Ids::WatchId const& watchId) const { - return 0.0; + auto const* progress = progressOf(watchId); + if (progress == nullptr) + return 0.0; + if (progress->bytesTotal > 0u) + return std::clamp( + static_cast(progress->bytesDone) / static_cast(progress->bytesTotal), 0.0, 1.0 + ); + const auto changes = progress->changesDone + progress->changesPending; + if (changes == 0u) + return 0.0; + return static_cast(progress->changesDone) / static_cast(changes); } std::optional TrackingModel::newestConflict() const { - return std::nullopt; + std::optional newest; + for (auto const& conflict : conflicts_ | std::views::values) + { + if (entries_.contains(conflict.watchId) && (!newest.has_value() || isNewer(conflict, *newest))) + newest = conflict; + } + return newest; } } diff --git a/frontend/test/frontend/test_tracking_model.hpp b/frontend/test/frontend/test_tracking_model.hpp index ad15d82e..d8475e0d 100644 --- a/frontend/test/frontend/test_tracking_model.hpp +++ b/frontend/test/frontend/test_tracking_model.hpp @@ -1,5 +1,860 @@ #pragma once +#include "test_file_tracking_texts.hpp" + #include +#include +#include #include + +#include +#include +#include +#include +#include + +namespace FileTracking::Test +{ + namespace TrackingModelTestDetail + { + using FileTrackingTextsTestDetail::localPath; + using FileTrackingTextsTestDetail::sampleTexts; + using Names = std::vector; + + inline Paths::RemotePath remotePath(std::string_view path) + { + return *Paths::RemotePath::make(path); + } + + inline Ids::WatchId watchIdOf(std::string const& watchId) + { + return Ids::makeWatchId(watchId); + } + + inline SharedData::TrackedEntry watchEntry(std::string const& watchId, std::string const& name) + { + SharedData::TrackedEntry entry{}; + entry.watchId = Ids::makeWatchId(watchId); + entry.name = name; + entry.remotePath = remotePath(fmt::format("/srv/{}", name)); + entry.localPath = localPath(fmt::format("/home/user/{}", name)); + return entry; + } + + inline SharedData::TrackedEntry + stoppedEntry(std::string const& watchId, std::string const& name, SharedData::WatchStopReason reason) + { + auto entry = watchEntry(watchId, name); + entry.stop = SharedData::WatchStop{.reason = reason, .stoppedAt = 1'700'000'000u}; + return entry; + } + + inline SharedData::TrackedEntry + copyEntry(std::string const& watchId, std::string const& name, std::string const& instanceId) + { + auto entry = watchEntry(watchId, name); + entry.downloadedCopy = true; + entry.instanceId = Ids::makeInstanceId(instanceId); + return entry; + } + + inline SharedData::WatchConflict + conflictOf(std::string const& conflictId, std::string const& watchId, std::uint64_t raisedAt) + { + SharedData::WatchConflict conflict{}; + conflict.conflictId = Ids::makeConflictId(conflictId); + conflict.watchId = Ids::makeWatchId(watchId); + conflict.localPath = localPath("/home/user/notes.txt"); + conflict.remotePath = remotePath("/srv/notes.txt"); + conflict.raisedAt = raisedAt; + return conflict; + } + + inline SharedData::UploadFailure + failureOf(std::string const& failureId, std::string const& watchId, std::uint64_t failedAt) + { + SharedData::UploadFailure failure{}; + failure.failureId = Ids::makeFailureId(failureId); + failure.watchId = Ids::makeWatchId(watchId); + failure.localPath = localPath("/home/user/notes.txt"); + failure.remotePath = remotePath("/srv/notes.txt"); + failure.failedAt = failedAt; + return failure; + } + + inline SharedData::WatchProgress progressOf( + std::string const& watchId, + std::uint64_t changesPending, + std::uint64_t changesDone, + std::uint64_t bytesDone, + std::uint64_t bytesTotal + ) + { + SharedData::WatchProgress progress{}; + progress.watchId = Ids::makeWatchId(watchId); + progress.changesPending = changesPending; + progress.changesDone = changesDone; + progress.bytesDone = bytesDone; + progress.bytesTotal = bytesTotal; + return progress; + } + + inline SharedData::OrphanedCopy orphanOf(std::string const& instanceId, std::string const& name) + { + SharedData::OrphanedCopy copy{}; + copy.instanceId = Ids::makeInstanceId(instanceId); + copy.name = name; + copy.directory = localPath(fmt::format("/home/user/.cache/nui-sftp/{}", instanceId)); + copy.copyPath = localPath(fmt::format("/home/user/.cache/nui-sftp/{}/{}", instanceId, name)); + return copy; + } + + inline SharedData::OrphanedCopies orphansOf(std::vector copies) + { + return SharedData::OrphanedCopies{.copies = std::move(copies)}; + } + + /** + * @brief "watch:" or "instance:" per member, readable in failure messages. + */ + inline Names namesOf(std::vector const& members) + { + Names names; + for (auto const& member : members) + { + if (auto const* watchId = std::get_if(&member)) + names.push_back(fmt::format("watch:{}", watchId->value())); + else if (auto const* instanceId = std::get_if(&member)) + names.push_back(fmt::format("instance:{}", instanceId->value())); + else + names.push_back("none"); + } + return names; + } + + inline Names conflictNamesOf(std::vector const& conflicts) + { + Names names; + for (auto const& conflict : conflicts) + names.push_back(conflict.conflictId.value()); + return names; + } + + inline Names failureNamesOf(std::vector const& failures) + { + Names names; + for (auto const& failure : failures) + names.push_back(failure.failureId.value()); + return names; + } + + inline Names membersOf(TrackingModel const& model, OutlinerGroup group, std::string_view filter = {}) + { + return namesOf(model.members(group, filter)); + } + } + + TEST(TrackingModelTests, AnUnknownWatchIsWatchingWithoutText) + { + using namespace TrackingModelTestDetail; + const TrackingModel model; + const auto texts = sampleTexts(); + + EXPECT_EQ(model.entryOf(watchIdOf("unknown")), nullptr); + EXPECT_EQ(model.progressOf(watchIdOf("unknown")), nullptr); + EXPECT_EQ(model.statusOf(watchIdOf("unknown")), WatchStatus::Watching); + EXPECT_EQ(model.groupOf(watchIdOf("unknown")), OutlinerGroup::Watching); + EXPECT_EQ(model.sideTextOf(watchIdOf("unknown"), texts), ""); + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("unknown")), 0.0); + } + + TEST(TrackingModelTests, AnIdleWatchIsWatchingAndUpToDate) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + model.showEntry(watchEntry("watch", "notes.txt")); + + ASSERT_NE(model.entryOf(watchIdOf("watch")), nullptr); + EXPECT_EQ(model.entryOf(watchIdOf("watch"))->name, "notes.txt"); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Watching); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Watching); + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "Up to date"); + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), ""); + } + + TEST(TrackingModelTests, AnUploadingEntryIsSyncing) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + auto entry = watchEntry("watch", "notes.txt"); + entry.uploading = true; + model.showEntry(entry); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Syncing); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Syncing); + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "Syncing"); + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), ""); + } + + TEST(TrackingModelTests, PendingChangesAreSyncingUntilTheyEnd) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showWatchProgress(progressOf("watch", 2u, 1u, 0u, 0u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Syncing); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Syncing); + + model.showWatchProgress(progressOf("watch", 0u, 3u, 0u, 0u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Watching); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Watching); + } + + TEST(TrackingModelTests, ConflictsNeedYouAndAreCounted) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + model.showEntry(watchEntry("watch", "notes.txt")); + model.showConflict(conflictOf("conflict-1", "watch", 100u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Conflicts); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "1 conflict"); + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 conflict"); + + model.showConflict(conflictOf("conflict-2", "watch", 200u)); + + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "2 conflicts"); + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "2 conflicts"); + } + + TEST(TrackingModelTests, FailuresNeedYouAndAreCounted) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + model.showEntry(watchEntry("watch", "notes.txt")); + model.showFailure(failureOf("failure-1", "watch", 100u)); + model.showFailure(failureOf("failure-2", "watch", 200u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Failed); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "2 failed"); + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "2 failed"); + } + + TEST(TrackingModelTests, ConflictsWinOverFailures) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + model.showEntry(watchEntry("watch", "notes.txt")); + model.showFailure(failureOf("failure", "watch", 100u)); + model.showConflict(conflictOf("conflict", "watch", 100u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Conflicts); + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "1 conflict"); + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 conflict"); + } + + TEST(TrackingModelTests, NeedingYouWinsOverSyncing) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + auto entry = watchEntry("watch", "project"); + entry.uploading = true; + model.showEntry(entry); + model.showWatchProgress(progressOf("watch", 4u, 0u, 10u, 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Failed); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); + + model.showConflict(conflictOf("conflict", "watch", 100u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Conflicts); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); + } + + TEST(TrackingModelTests, StoppedWinsOverEverythingElse) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + auto entry = stoppedEntry("watch", "project", SharedData::WatchStopReason::StoppedByUser); + entry.uploading = true; + model.showEntry(entry); + model.showWatchProgress(progressOf("watch", 4u, 0u, 10u, 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + model.showConflict(conflictOf("conflict", "watch", 100u)); + + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Stopped); + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Stopped); + EXPECT_EQ(model.statusTextOf(watchIdOf("watch"), texts), "Stopped"); + } + + TEST(TrackingModelTests, TheSideTextPrefersConflictsThenFailuresThenTheStopReason) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + model.showEntry(stoppedEntry("watch", "project", SharedData::WatchStopReason::Overlap)); + model.showConflict(conflictOf("conflict", "watch", 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 conflict"); + + model.removeConflict(Ids::makeConflictId("conflict")); + + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 failed"); + + model.removeFailure(Ids::makeFailureId("failure")); + + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "overlap"); + } + + TEST(TrackingModelTests, AStoppedWatchShowsItsReasonBesideIt) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + const auto texts = sampleTexts(); + model.showEntry(stoppedEntry("user", "user.txt", SharedData::WatchStopReason::StoppedByUser)); + model.showEntry(stoppedEntry("overlap", "overlap.txt", SharedData::WatchStopReason::Overlap)); + model.showEntry(stoppedEntry("gone", "gone.txt", SharedData::WatchStopReason::CopyGone)); + model.showEntry(stoppedEntry("lost", "lost.txt", SharedData::WatchStopReason::ConnectionLost)); + + EXPECT_EQ(model.sideTextOf(watchIdOf("user"), texts), "stopped"); + EXPECT_EQ(model.sideTextOf(watchIdOf("overlap"), texts), "overlap"); + EXPECT_EQ(model.sideTextOf(watchIdOf("gone"), texts), "deleted"); + EXPECT_EQ(model.sideTextOf(watchIdOf("lost"), texts), "connection lost"); + EXPECT_EQ( + membersOf(model, OutlinerGroup::Stopped), (Names{"watch:gone", "watch:lost", "watch:overlap", "watch:user"}) + ); + } + + TEST(TrackingModelTests, AnsweredConflictsAndHandledFailuresReturnTheWatchToItsGroup) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "notes.txt")); + model.showConflict(conflictOf("conflict", "watch", 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + model.removeConflict(Ids::makeConflictId("conflict")); + + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Failed); + + model.removeFailure(Ids::makeFailureId("failure")); + + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Watching); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Watching); + } + + TEST(TrackingModelTests, ShowingAnEntryAgainReplacesIt) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "notes.txt")); + model.showEntry(stoppedEntry("watch", "renamed.txt", SharedData::WatchStopReason::StoppedByUser)); + + ASSERT_NE(model.entryOf(watchIdOf("watch")), nullptr); + EXPECT_EQ(model.entryOf(watchIdOf("watch"))->name, "renamed.txt"); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Stopped); + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching), Names{}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Stopped), Names{"watch:watch"}); + } + + TEST(TrackingModelTests, EachGroupHoldsOnlyItsMembers) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + auto uploading = watchEntry("uploading", "uploading.txt"); + uploading.uploading = true; + model.showEntry(uploading); + model.showEntry(watchEntry("pending", "pending.txt")); + model.showWatchProgress(progressOf("pending", 1u, 0u, 0u, 0u)); + model.showEntry(watchEntry("conflicting", "conflicting.txt")); + model.showConflict(conflictOf("conflict", "conflicting", 100u)); + model.showEntry(watchEntry("failing", "failing.txt")); + model.showFailure(failureOf("failure", "failing", 100u)); + model.showEntry(watchEntry("idle", "idle.txt")); + model.showEntry(stoppedEntry("stopped", "stopped.txt", SharedData::WatchStopReason::CopyGone)); + model.showOrphanedCopies(orphansOf({orphanOf("orphan", "orphan.txt")})); + + EXPECT_EQ(membersOf(model, OutlinerGroup::NeedsYou), (Names{"watch:conflicting", "watch:failing"})); + EXPECT_EQ(membersOf(model, OutlinerGroup::Syncing), (Names{"watch:pending", "watch:uploading"})); + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching), Names{"watch:idle"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Stopped), Names{"watch:stopped"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), Names{"instance:orphan"}); + } + + TEST(TrackingModelTests, MembersAreSortedByNameIgnoringCase) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "gamma.txt")); + model.showEntry(watchEntry("second", "Alpha.txt")); + model.showEntry(watchEntry("third", "beta.txt")); + model.showEntry(watchEntry("fourth", "alpha2.txt")); + + EXPECT_EQ( + membersOf(model, OutlinerGroup::Watching), + (Names{"watch:second", "watch:fourth", "watch:third", "watch:first"}) + ); + } + + TEST(TrackingModelTests, NamesThatDifferOnlyInCaseAreOrderedById) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("bravo", "Notes.txt")); + model.showEntry(watchEntry("charlie", "NOTES.txt")); + model.showEntry(watchEntry("alpha", "notes.txt")); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching), (Names{"watch:alpha", "watch:bravo", "watch:charlie"})); + } + + TEST(TrackingModelTests, TheFilterKeepsNamesThatContainItIgnoringCase) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("report", "Report.pdf")); + model.showEntry(watchEntry("notes", "notes.txt")); + model.showEntry(watchEntry("old", "REPORT-old.pdf")); + model.showEntry(watchEntry("summary", "summary.md")); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching, "report"), (Names{"watch:old", "watch:report"})); + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching, "PORT"), (Names{"watch:old", "watch:report"})); + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching, ".TXT"), Names{"watch:notes"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching, "-old.p"), Names{"watch:old"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching, "xyz"), Names{}); + EXPECT_EQ( + membersOf(model, OutlinerGroup::Watching, ""), + (Names{"watch:notes", "watch:old", "watch:report", "watch:summary"}) + ); + } + + TEST(TrackingModelTests, MatchesFilterComparesAsciiLettersWithoutCase) + { + EXPECT_TRUE(matchesFilter("Report.pdf", "")); + EXPECT_TRUE(matchesFilter("", "")); + EXPECT_TRUE(matchesFilter("Report.pdf", "report")); + EXPECT_TRUE(matchesFilter("Report.pdf", "PDF")); + EXPECT_TRUE(matchesFilter("Report.pdf", "Report.pdf")); + EXPECT_TRUE(matchesFilter("\xc3\x84rger.txt", "\xc3\x84rger")); + EXPECT_FALSE(matchesFilter("\xc3\x84rger.txt", "\xc3\xa4rger")); + EXPECT_FALSE(matchesFilter("Report.pdf", "rep.pdf")); + EXPECT_FALSE(matchesFilter("Report.pdf", "Report.pdf2")); + EXPECT_FALSE(matchesFilter("", "a")); + } + + TEST(TrackingModelTests, TheFilterAppliesToOrphansToo) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showOrphanedCopies(orphansOf({orphanOf("notes", "notes.txt"), orphanOf("report", "report.pdf")})); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned, "REP"), Names{"instance:report"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned, "zzz"), Names{}); + } + + TEST(TrackingModelTests, OrphansAreSortedByNameIgnoringCaseTiesById) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showOrphanedCopies( + orphansOf({orphanOf("charlie", "b.txt"), orphanOf("bravo", "a.txt"), orphanOf("alpha", "B.txt")}) + ); + + EXPECT_EQ( + membersOf(model, OutlinerGroup::Orphaned), (Names{"instance:bravo", "instance:alpha", "instance:charlie"}) + ); + } + + TEST(TrackingModelTests, OrphansAreOnlyMembersOfOrphaned) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "notes.txt")); + model.showOrphanedCopies(orphansOf({orphanOf("orphan", "notes.txt")})); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Watching), Names{"watch:watch"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), Names{"instance:orphan"}); + for (auto const group : {OutlinerGroup::NeedsYou, OutlinerGroup::Syncing, OutlinerGroup::Stopped}) + EXPECT_EQ(membersOf(model, group), Names{}); + } + + TEST(TrackingModelTests, AStoppedCopyIsHiddenFromTheOrphansUntilItsEntryIsRemoved) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showOrphanedCopies(orphansOf({orphanOf("held", "notes.txt"), orphanOf("free", "other.txt")})); + auto entry = copyEntry("watch", "notes.txt", "held"); + entry.stop = SharedData::WatchStop{.reason = SharedData::WatchStopReason::StoppedByUser, .stoppedAt = 100u}; + model.showEntry(entry); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), Names{"instance:free"}); + EXPECT_EQ(membersOf(model, OutlinerGroup::Stopped), Names{"watch:watch"}); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("held")), nullptr); + EXPECT_NE(model.orphanOf(Ids::makeInstanceId("free")), nullptr); + + model.removeEntry(watchIdOf("watch")); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), (Names{"instance:held", "instance:free"})); + EXPECT_EQ(membersOf(model, OutlinerGroup::Stopped), Names{}); + ASSERT_NE(model.orphanOf(Ids::makeInstanceId("held")), nullptr); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("held"))->name, "notes.txt"); + } + + TEST(TrackingModelTests, ARunningCopyHoldsItsInstanceToo) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(copyEntry("watch", "notes.txt", "held")); + model.showOrphanedCopies(orphansOf({orphanOf("held", "notes.txt")})); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), Names{}); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("held")), nullptr); + } + + TEST(TrackingModelTests, OrphanedCopiesReplaceTheListShownBefore) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showOrphanedCopies(orphansOf({orphanOf("first", "first.txt"), orphanOf("second", "second.txt")})); + model.showOrphanedCopies(orphansOf({orphanOf("third", "third.txt")})); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), Names{"instance:third"}); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("first")), nullptr); + ASSERT_NE(model.orphanOf(Ids::makeInstanceId("third")), nullptr); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("third"))->name, "third.txt"); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("unknown")), nullptr); + + model.showOrphanedCopies(orphansOf({})); + + EXPECT_EQ(membersOf(model, OutlinerGroup::Orphaned), Names{}); + } + + TEST(TrackingModelTests, ConflictsAndFailuresAreKeptBeforeTheirEntryArrives) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showConflict(conflictOf("conflict", "watch", 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("conflict")), watchIdOf("watch")); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("failure")), watchIdOf("watch")); + EXPECT_EQ(model.conflictCountOf(watchIdOf("watch")), 1u); + EXPECT_EQ(model.failureCountOf(watchIdOf("watch")), 1u); + EXPECT_EQ(model.conflictCount(), 0u); + EXPECT_EQ(model.failureCount(), 0u); + EXPECT_FALSE(model.newestConflict().has_value()); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Watching); + + model.showEntry(watchEntry("watch", "notes.txt")); + + EXPECT_EQ(model.conflictCount(), 1u); + EXPECT_EQ(model.failureCount(), 1u); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Conflicts); + ASSERT_TRUE(model.newestConflict().has_value()); + EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("conflict")); + } + + TEST(TrackingModelTests, RemovingAnEntryDropsItsConflictsFailuresAndProgress) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("removed", "removed.txt")); + model.showEntry(watchEntry("kept", "kept.txt")); + model.showConflict(conflictOf("removed-conflict", "removed", 100u)); + model.showConflict(conflictOf("kept-conflict", "kept", 100u)); + model.showFailure(failureOf("removed-failure", "removed", 100u)); + model.showFailure(failureOf("kept-failure", "kept", 100u)); + model.showWatchProgress(progressOf("removed", 1u, 0u, 0u, 0u)); + model.showWatchProgress(progressOf("kept", 1u, 0u, 0u, 0u)); + model.removeEntry(watchIdOf("removed")); + + EXPECT_EQ(model.entryOf(watchIdOf("removed")), nullptr); + EXPECT_EQ(model.progressOf(watchIdOf("removed")), nullptr); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("removed-conflict")), std::nullopt); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("removed-failure")), std::nullopt); + EXPECT_TRUE(model.conflictsOf(watchIdOf("removed")).empty()); + EXPECT_TRUE(model.failuresOf(watchIdOf("removed")).empty()); + EXPECT_EQ(model.conflictCount(), 1u); + EXPECT_EQ(model.failureCount(), 1u); + EXPECT_NE(model.progressOf(watchIdOf("kept")), nullptr); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("kept-conflict")), watchIdOf("kept")); + EXPECT_EQ(membersOf(model, OutlinerGroup::NeedsYou), Names{"watch:kept"}); + + model.showEntry(watchEntry("removed", "removed.txt")); + + EXPECT_EQ(model.conflictCountOf(watchIdOf("removed")), 0u); + EXPECT_EQ(model.failureCountOf(watchIdOf("removed")), 0u); + EXPECT_EQ(model.progressOf(watchIdOf("removed")), nullptr); + EXPECT_EQ(model.groupOf(watchIdOf("removed")), OutlinerGroup::Watching); + } + + TEST(TrackingModelTests, AConflictIsReplacedById) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "first.txt")); + model.showEntry(watchEntry("second", "second.txt")); + model.showConflict(conflictOf("conflict", "first", 100u)); + auto replaced = conflictOf("conflict", "first", 200u); + replaced.kind = SharedData::ConflictKind::DeletedOnServer; + model.showConflict(replaced); + + const auto conflicts = model.conflictsOf(watchIdOf("first")); + ASSERT_EQ(conflicts.size(), 1u); + EXPECT_EQ(conflicts.front().raisedAt, 200u); + EXPECT_EQ(conflicts.front().kind, SharedData::ConflictKind::DeletedOnServer); + EXPECT_EQ(model.conflictCount(), 1u); + + model.showConflict(conflictOf("conflict", "second", 300u)); + + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("conflict")), watchIdOf("second")); + EXPECT_EQ(model.conflictCountOf(watchIdOf("first")), 0u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("second")), 1u); + } + + TEST(TrackingModelTests, AFailureIsReplacedById) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "notes.txt")); + model.showFailure(failureOf("failure", "watch", 100u)); + auto repeated = failureOf("failure", "watch", 300u); + repeated.attempts = 3u; + repeated.reason = SharedData::UploadFailureReason::NoSpaceOnServer; + model.showFailure(repeated); + + const auto failures = model.failuresOf(watchIdOf("watch")); + ASSERT_EQ(failures.size(), 1u); + EXPECT_EQ(failures.front().attempts, 3u); + EXPECT_EQ(failures.front().failedAt, 300u); + EXPECT_EQ(failures.front().reason, SharedData::UploadFailureReason::NoSpaceOnServer); + EXPECT_EQ(model.failureCount(), 1u); + } + + TEST(TrackingModelTests, RemovingUnknownItemsChangesNothing) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "notes.txt")); + model.showConflict(conflictOf("conflict", "watch", 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + model.removeConflict(Ids::makeConflictId("unknown")); + model.removeFailure(Ids::makeFailureId("unknown")); + model.removeEntry(watchIdOf("unknown")); + + EXPECT_EQ(model.conflictCount(), 1u); + EXPECT_EQ(model.failureCount(), 1u); + EXPECT_NE(model.entryOf(watchIdOf("watch")), nullptr); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("unknown")), std::nullopt); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("unknown")), std::nullopt); + } + + TEST(TrackingModelTests, TheConflictsOfAWatchStartWithTheNewest) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showEntry(watchEntry("other", "other")); + model.showConflict(conflictOf("first", "watch", 100u)); + model.showConflict(conflictOf("second", "watch", 300u)); + model.showConflict(conflictOf("third", "watch", 200u)); + model.showConflict(conflictOf("elsewhere", "other", 400u)); + + EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("watch"))), (Names{"second", "third", "first"})); + EXPECT_EQ(model.conflictCountOf(watchIdOf("watch")), 3u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("other")), 1u); + } + + TEST(TrackingModelTests, ConflictsRaisedAtTheSameTimeStartWithTheGreaterId) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showConflict(conflictOf("alpha", "watch", 100u)); + model.showConflict(conflictOf("charlie", "watch", 50u)); + model.showConflict(conflictOf("bravo", "watch", 100u)); + + EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("watch"))), (Names{"bravo", "alpha", "charlie"})); + } + + TEST(TrackingModelTests, TheFailuresOfAWatchStartWithTheNewestTiesByTheGreaterId) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showFailure(failureOf("alpha", "watch", 100u)); + model.showFailure(failureOf("bravo", "watch", 300u)); + model.showFailure(failureOf("charlie", "watch", 100u)); + model.showFailure(failureOf("delta", "other", 500u)); + + EXPECT_EQ(failureNamesOf(model.failuresOf(watchIdOf("watch"))), (Names{"bravo", "charlie", "alpha"})); + EXPECT_EQ(model.failureCountOf(watchIdOf("watch")), 3u); + } + + TEST(TrackingModelTests, TheTotalsCountEveryKnownWatch) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "first.txt")); + model.showEntry(stoppedEntry("second", "second.txt", SharedData::WatchStopReason::StoppedByUser)); + model.showConflict(conflictOf("first-1", "first", 100u)); + model.showConflict(conflictOf("first-2", "first", 100u)); + model.showConflict(conflictOf("second-1", "second", 100u)); + model.showConflict(conflictOf("unknown-1", "unknown", 100u)); + model.showFailure(failureOf("second-1", "second", 100u)); + model.showFailure(failureOf("unknown-1", "unknown", 100u)); + + EXPECT_EQ(model.conflictCount(), 3u); + EXPECT_EQ(model.failureCount(), 1u); + } + + TEST(TrackingModelTests, TheNewestConflictHasTheLatestRaisedAt) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "first.txt")); + model.showEntry(watchEntry("second", "second.txt")); + model.showConflict(conflictOf("older", "first", 100u)); + model.showConflict(conflictOf("newest", "second", 300u)); + model.showConflict(conflictOf("newer", "first", 200u)); + model.showConflict(conflictOf("unknown", "unknown", 900u)); + + ASSERT_TRUE(model.newestConflict().has_value()); + EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("newest")); + EXPECT_EQ(model.newestConflict()->watchId, watchIdOf("second")); + + model.removeConflict(Ids::makeConflictId("newest")); + + ASSERT_TRUE(model.newestConflict().has_value()); + EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("newer")); + } + + TEST(TrackingModelTests, NewestConflictTiesGoToTheGreaterId) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "first.txt")); + model.showEntry(watchEntry("second", "second.txt")); + model.showConflict(conflictOf("bravo", "first", 100u)); + model.showConflict(conflictOf("alpha", "second", 100u)); + + ASSERT_TRUE(model.newestConflict().has_value()); + EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("bravo")); + EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("first"))).front(), "bravo"); + } + + TEST(TrackingModelTests, NoConflictsMeanNoNewestConflict) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + + EXPECT_FALSE(model.newestConflict().has_value()); + + model.showEntry(watchEntry("watch", "notes.txt")); + model.showConflict(conflictOf("conflict", "watch", 100u)); + model.removeConflict(Ids::makeConflictId("conflict")); + + EXPECT_FALSE(model.newestConflict().has_value()); + } + + TEST(TrackingModelTests, TheRingFollowsTheBytesWhenTheirTotalIsKnown) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showWatchProgress(progressOf("watch", 3u, 1u, 25u, 100u)); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 0.25); + + model.showWatchProgress(progressOf("watch", 0u, 4u, 100u, 100u)); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 1.0); + } + + TEST(TrackingModelTests, TheRingFollowsTheChangesWithoutAByteTotal) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showWatchProgress(progressOf("watch", 1u, 3u, 0u, 0u)); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 0.75); + + model.showWatchProgress(progressOf("watch", 4u, 0u, 50u, 0u)); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 0.0); + } + + TEST(TrackingModelTests, TheRingIsEmptyWithoutProgressOrChanges) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 0.0); + + model.showWatchProgress(progressOf("watch", 0u, 0u, 0u, 0u)); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 0.0); + } + + TEST(TrackingModelTests, TheRingNeverPassesFull) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showWatchProgress(progressOf("watch", 1u, 0u, 150u, 100u)); + + EXPECT_DOUBLE_EQ(model.ringFractionOf(watchIdOf("watch")), 1.0); + } + + TEST(TrackingModelTests, NewProgressReplacesTheOld) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showWatchProgress(progressOf("watch", 5u, 0u, 10u, 100u)); + model.showWatchProgress(progressOf("watch", 2u, 3u, 60u, 100u)); + + ASSERT_NE(model.progressOf(watchIdOf("watch")), nullptr); + EXPECT_EQ(model.progressOf(watchIdOf("watch"))->changesPending, 2u); + EXPECT_EQ(model.progressOf(watchIdOf("watch"))->changesDone, 3u); + EXPECT_EQ(model.progressOf(watchIdOf("watch"))->bytesDone, 60u); + } + + TEST(TrackingModelTests, ClearForgetsEverything) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "notes.txt")); + model.showEntry(stoppedEntry("stopped", "stopped.txt", SharedData::WatchStopReason::ConnectionLost)); + model.showConflict(conflictOf("conflict", "watch", 100u)); + model.showFailure(failureOf("failure", "watch", 100u)); + model.showWatchProgress(progressOf("watch", 1u, 0u, 0u, 0u)); + model.showOrphanedCopies(orphansOf({orphanOf("orphan", "orphan.txt")})); + model.clear(); + + EXPECT_EQ(model.entryOf(watchIdOf("watch")), nullptr); + EXPECT_EQ(model.progressOf(watchIdOf("watch")), nullptr); + EXPECT_EQ(model.orphanOf(Ids::makeInstanceId("orphan")), nullptr); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("conflict")), std::nullopt); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("failure")), std::nullopt); + EXPECT_EQ(model.conflictCount(), 0u); + EXPECT_EQ(model.failureCount(), 0u); + EXPECT_FALSE(model.newestConflict().has_value()); + for (auto const group : outlinerGroupOrder) + EXPECT_EQ(membersOf(model, group), Names{}); + } +} From 07a5efefcbc22c063d6e37cb8e31a7237dc5b8ae Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:38:42 +0200 Subject: [PATCH 059/114] Implemented the dock tab attention state with one pulse per burst and tests --- .../file_tracking/attention_state.hpp | 26 +- .../test/frontend/test_attention_state.hpp | 252 ++++++++++++++++++ 2 files changed, 273 insertions(+), 5 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/attention_state.hpp b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp index e9a7b9b0..e5c152e4 100644 --- a/frontend/include/frontend/session_components/file_tracking/attention_state.hpp +++ b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp @@ -46,10 +46,20 @@ namespace FileTracking * conflicts left clears unseen. * @return What the tab shows now, nothing when neither the counts changed nor a pulse is due. */ - std::optional - update([[maybe_unused]] AttentionCounts const& counts, [[maybe_unused]] std::chrono::milliseconds now) + std::optional update(AttentionCounts const& counts, std::chrono::milliseconds now) { - return std::nullopt; + if (counts == counts_) + return std::nullopt; + const auto arrived = counts.conflicts > counts_.conflicts; + counts_ = counts; + if (counts_.conflicts == 0u) + unseen_ = false; + else if (arrived) + unseen_ = true; + const auto pulse = arrived && (!lastPulse_.has_value() || now - *lastPulse_ >= pulseWindow); + if (pulse) + lastPulse_ = now; + return Attention{.counts = counts_, .pulse = pulse}; } /** @@ -64,7 +74,9 @@ namespace FileTracking * @brief The user jumped to the newest conflict, what arrived so far counts as seen. */ void acknowledge() - {} + { + unseen_ = false; + } /** * @return The counts of the last @ref update. @@ -78,7 +90,11 @@ namespace FileTracking * @brief Forgets the counts, unseen and the last pulse, the session ended. */ void reset() - {} + { + counts_ = {}; + lastPulse_.reset(); + unseen_ = false; + } private: AttentionCounts counts_{}; diff --git a/frontend/test/frontend/test_attention_state.hpp b/frontend/test/frontend/test_attention_state.hpp index 5fc6bbbe..f2b35cd4 100644 --- a/frontend/test/frontend/test_attention_state.hpp +++ b/frontend/test/frontend/test_attention_state.hpp @@ -3,3 +3,255 @@ #include #include + +#include +#include +#include +#include + +namespace FileTracking +{ + inline void PrintTo(AttentionCounts const& counts, std::ostream* stream) + { + *stream << "{conflicts: " << counts.conflicts << ", failed: " << counts.failed << "}"; + } + + inline void PrintTo(Attention const& attention, std::ostream* stream) + { + *stream << "{conflicts: " << attention.counts.conflicts << ", failed: " << attention.counts.failed + << ", pulse: " << std::boolalpha << attention.pulse << "}"; + } +} + +namespace FileTracking::Test +{ + namespace AttentionStateTestDetail + { + inline AttentionCounts counts(std::uint64_t conflicts, std::uint64_t failed) + { + return AttentionCounts{.conflicts = conflicts, .failed = failed}; + } + + inline std::optional shown(std::uint64_t conflicts, std::uint64_t failed, bool pulse) + { + return Attention{.counts = counts(conflicts, failed), .pulse = pulse}; + } + } + + TEST(AttentionStateTests, StartsWithNothingSeenOrCounted) + { + const AttentionState state; + + EXPECT_FALSE(state.unseen()); + EXPECT_EQ(state.counts(), AttentionStateTestDetail::counts(0u, 0u)); + } + + TEST(AttentionStateTests, TheFirstConflictPulsesAndIsUnseen) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 1'000ms), shown(1u, 0u, true)); + EXPECT_TRUE(state.unseen()); + EXPECT_EQ(state.counts(), counts(1u, 0u)); + } + + TEST(AttentionStateTests, UnchangedCountsShowNothing) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(0u, 0u), 0ms), std::nullopt); + EXPECT_EQ(state.update(counts(2u, 1u), 100ms), shown(2u, 1u, true)); + EXPECT_EQ(state.update(counts(2u, 1u), 200ms), std::nullopt); + EXPECT_EQ(state.update(counts(2u, 1u), 10'000ms), std::nullopt); + } + + TEST(AttentionStateTests, ConflictsWithinTheWindowOfAPulseDoNotPulseAgain) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 0ms), shown(1u, 0u, true)); + EXPECT_EQ(state.update(counts(2u, 0u), 1'000ms), shown(2u, 0u, false)); + EXPECT_EQ(state.update(counts(3u, 0u), 2'499ms), shown(3u, 0u, false)); + EXPECT_TRUE(state.unseen()); + } + + TEST(AttentionStateTests, AConflictOnceTheWindowEndedPulsesAgain) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 0ms), shown(1u, 0u, true)); + EXPECT_EQ(state.update(counts(2u, 0u), AttentionState::pulseWindow), shown(2u, 0u, true)); + EXPECT_EQ(state.update(counts(3u, 0u), 10'000ms), shown(3u, 0u, true)); + } + + TEST(AttentionStateTests, TheWindowStartsWithThePulseNotWithTheLastConflict) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 0ms), shown(1u, 0u, true)); + EXPECT_EQ(state.update(counts(2u, 0u), 2'000ms), shown(2u, 0u, false)); + EXPECT_EQ(state.update(counts(3u, 0u), 2'600ms), shown(3u, 0u, true)); + EXPECT_EQ(state.update(counts(4u, 0u), 5'000ms), shown(4u, 0u, false)); + EXPECT_EQ(state.update(counts(5u, 0u), 5'100ms), shown(5u, 0u, true)); + } + + TEST(AttentionStateTests, ABurstOfConflictsWhileAPulsePlaysPulsesOnce) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 0ms), shown(1u, 0u, true)); + auto pulses = 0; + for (auto conflicts = 2u; conflicts <= 24u; ++conflicts) + { + const auto attention = state.update(counts(conflicts, 0u), std::chrono::milliseconds{conflicts * 100u}); + ASSERT_TRUE(attention.has_value()); + EXPECT_EQ(attention->counts, counts(conflicts, 0u)); + pulses += attention->pulse ? 1 : 0; + } + + EXPECT_EQ(pulses, 0); + EXPECT_EQ(state.update(counts(25u, 0u), 2'600ms), shown(25u, 0u, true)); + } + + TEST(AttentionStateTests, ConflictsAtTheSameMomentPulseOnce) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 500ms), shown(1u, 0u, true)); + EXPECT_EQ(state.update(counts(2u, 0u), 500ms), shown(2u, 0u, false)); + EXPECT_EQ(state.update(counts(3u, 0u), 500ms), shown(3u, 0u, false)); + } + + TEST(AttentionStateTests, FailuresNeverPulseNorMakeAnythingUnseen) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(0u, 1u), 0ms), shown(0u, 1u, false)); + EXPECT_EQ(state.update(counts(0u, 4u), 10'000ms), shown(0u, 4u, false)); + EXPECT_FALSE(state.unseen()); + } + + TEST(AttentionStateTests, FailuresDoNotStartTheWindow) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(0u, 1u), 0ms), shown(0u, 1u, false)); + EXPECT_EQ(state.update(counts(1u, 1u), 100ms), shown(1u, 1u, true)); + } + + TEST(AttentionStateTests, MoreFailuresBesideConflictsDoNotPulse) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(1u, 0u), 0ms), shown(1u, 0u, true)); + EXPECT_EQ(state.update(counts(1u, 1u), 5'000ms), shown(1u, 1u, false)); + EXPECT_EQ(state.update(counts(1u, 0u), 6'000ms), shown(1u, 0u, false)); + EXPECT_TRUE(state.unseen()); + } + + TEST(AttentionStateTests, FewerConflictsDoNotPulseNorMakeThemUnseen) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + + EXPECT_EQ(state.update(counts(3u, 0u), 0ms), shown(3u, 0u, true)); + state.acknowledge(); + + EXPECT_EQ(state.update(counts(2u, 0u), 5'000ms), shown(2u, 0u, false)); + EXPECT_FALSE(state.unseen()); + } + + TEST(AttentionStateTests, AcknowledgeMakesTheConflictsSoFarSeen) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + state.update(counts(2u, 1u), 0ms); + state.acknowledge(); + + EXPECT_FALSE(state.unseen()); + EXPECT_EQ(state.counts(), counts(2u, 1u)); + EXPECT_EQ(state.update(counts(2u, 1u), 100ms), std::nullopt); + EXPECT_FALSE(state.unseen()); + } + + TEST(AttentionStateTests, AConflictAfterAcknowledgeIsUnseenAgain) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + state.update(counts(1u, 0u), 0ms); + state.acknowledge(); + + EXPECT_EQ(state.update(counts(2u, 0u), 1'000ms), shown(2u, 0u, false)); + EXPECT_TRUE(state.unseen()); + + state.acknowledge(); + + EXPECT_FALSE(state.unseen()); + } + + TEST(AttentionStateTests, NoConflictsLeftClearUnseen) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + state.update(counts(2u, 1u), 0ms); + + EXPECT_EQ(state.update(counts(0u, 1u), 100ms), shown(0u, 1u, false)); + EXPECT_FALSE(state.unseen()); + } + + TEST(AttentionStateTests, AConflictAfterAllWereAnsweredIsUnseenAndPulsesOnceTheWindowEnded) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + state.update(counts(1u, 0u), 0ms); + state.update(counts(0u, 0u), 500ms); + + EXPECT_EQ(state.update(counts(1u, 0u), 1'000ms), shown(1u, 0u, false)); + EXPECT_TRUE(state.unseen()); + + state.update(counts(0u, 0u), 2'000ms); + + EXPECT_EQ(state.update(counts(1u, 0u), 3'000ms), shown(1u, 0u, true)); + EXPECT_TRUE(state.unseen()); + } + + TEST(AttentionStateTests, ResetForgetsCountsUnseenAndTheLastPulse) + { + using namespace AttentionStateTestDetail; + using namespace std::chrono_literals; + AttentionState state; + state.update(counts(2u, 3u), 0ms); + state.reset(); + + EXPECT_FALSE(state.unseen()); + EXPECT_EQ(state.counts(), counts(0u, 0u)); + EXPECT_EQ(state.update(counts(0u, 0u), 100ms), std::nullopt); + EXPECT_EQ(state.update(counts(1u, 0u), 200ms), shown(1u, 0u, true)); + EXPECT_TRUE(state.unseen()); + } +} From 319befc81427468c64a86ba53a0b2e87676ede4e Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:38:42 +0200 Subject: [PATCH 060/114] Implemented the stable in-flight row layout with tests --- .../file_tracking/in_flight_rows.hpp | 48 +++++- .../test/frontend/test_in_flight_rows.hpp | 156 ++++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp b/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp index 176004d7..3bdc5d41 100644 --- a/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp +++ b/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -20,8 +22,28 @@ namespace FileTracking * @brief Takes the keys in flight now: rows whose key is not among @p keys become free, then every key of * @p keys without a row claims one, then free rows at the end are dropped. */ - void update([[maybe_unused]] std::vector const& keys) - {} + void update(std::vector const& keys) + { + for (auto& row : rows_) + { + if (row.has_value() && std::ranges::find(keys, *row) == keys.end()) + row.reset(); + } + for (auto const& key : keys) + { + if (!rowOf(key).has_value()) + claimRow(key); + } + const auto lastTaken = std::ranges::find_if( + rows_.rbegin(), + rows_.rend(), + [](std::optional const& row) + { + return row.has_value(); + } + ); + rows_.erase(lastTaken.base(), rows_.end()); + } /** * @return The key of each row, nothing for a free row. Never ends with a free row. @@ -34,9 +56,12 @@ namespace FileTracking /** * @return The row of @p key, nothing when it is not in flight. */ - std::optional rowOf([[maybe_unused]] KeyT const& key) const + std::optional rowOf(KeyT const& key) const { - return std::nullopt; + const auto found = std::ranges::find(rows_, std::optional{key}); + if (found == rows_.end()) + return std::nullopt; + return static_cast(std::distance(rows_.begin(), found)); } /** @@ -56,6 +81,21 @@ namespace FileTracking } private: + void claimRow(KeyT const& key) + { + const auto freeRow = std::ranges::find_if( + rows_, + [](std::optional const& row) + { + return !row.has_value(); + } + ); + if (freeRow == rows_.end()) + rows_.emplace_back(key); + else + *freeRow = key; + } + std::vector> rows_{}; }; } diff --git a/frontend/test/frontend/test_in_flight_rows.hpp b/frontend/test/frontend/test_in_flight_rows.hpp index f877763c..2dd7d149 100644 --- a/frontend/test/frontend/test_in_flight_rows.hpp +++ b/frontend/test/frontend/test_in_flight_rows.hpp @@ -1,5 +1,161 @@ #pragma once #include +#include #include + +#include +#include +#include +#include + +namespace FileTracking::Test +{ + namespace InFlightRowsTestDetail + { + using Rows = std::vector>; + } + + TEST(InFlightRowsTests, NewKeysAppendRowsInTheirOrder) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"first.txt", "second.txt", "third.txt"})); + EXPECT_EQ(rows.size(), std::size_t{3}); + EXPECT_EQ(rows.rowOf("first.txt"), std::optional{0}); + EXPECT_EQ(rows.rowOf("second.txt"), std::optional{1}); + EXPECT_EQ(rows.rowOf("third.txt"), std::optional{2}); + } + + TEST(InFlightRowsTests, AKeyKeepsItsRowWhileItStaysInFlight) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + rows.update({"third.txt", "first.txt", "second.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"first.txt", "second.txt", "third.txt"})); + } + + TEST(InFlightRowsTests, AFinishedKeyFreesItsRowWithoutShiftingTheOthers) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + rows.update({"first.txt", "third.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"first.txt", std::nullopt, "third.txt"})); + EXPECT_EQ(rows.rowOf("second.txt"), std::nullopt); + EXPECT_EQ(rows.rowOf("third.txt"), std::optional{2}); + } + + TEST(InFlightRowsTests, ANewKeyReusesTheFirstFreeRow) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + rows.update({"first.txt", "third.txt"}); + rows.update({"first.txt", "third.txt", "fourth.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"first.txt", "fourth.txt", "third.txt"})); + } + + TEST(InFlightRowsTests, NewKeysFillFreeRowsInOrderBeforeAppending) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt", "fourth.txt"}); + rows.update({"second.txt", "fourth.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{std::nullopt, "second.txt", std::nullopt, "fourth.txt"})); + + rows.update({"second.txt", "fourth.txt", "fifth.txt", "sixth.txt", "seventh.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"fifth.txt", "second.txt", "sixth.txt", "fourth.txt", "seventh.txt"})); + } + + TEST(InFlightRowsTests, FreeRowsAtTheEndAreDropped) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + rows.update({"first.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"first.txt"})); + EXPECT_EQ(rows.size(), std::size_t{1}); + } + + TEST(InFlightRowsTests, FreeRowsBeforeATakenRowStay) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + rows.update({"second.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{std::nullopt, "second.txt"})); + EXPECT_EQ(rows.rowOf("second.txt"), std::optional{1}); + } + + TEST(InFlightRowsTests, NothingInFlightLeavesNoRows) + { + InFlightRows rows; + rows.update({"first.txt", "second.txt"}); + rows.update({}); + + EXPECT_TRUE(rows.rows().empty()); + EXPECT_EQ(rows.size(), std::size_t{0}); + EXPECT_EQ(rows.rowOf("first.txt"), std::nullopt); + } + + TEST(InFlightRowsTests, ARepeatedKeyTakesOneRow) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "first.txt", "second.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"first.txt", "second.txt"})); + } + + TEST(InFlightRowsTests, AKeyThatReturnsTakesAFreeRowAgain) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt", "third.txt"}); + rows.update({"third.txt"}); + rows.update({"third.txt", "second.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"second.txt", std::nullopt, "third.txt"})); + } + + TEST(InFlightRowsTests, ClearDropsEveryRow) + { + using namespace InFlightRowsTestDetail; + InFlightRows rows; + rows.update({"first.txt", "second.txt"}); + rows.clear(); + + EXPECT_TRUE(rows.rows().empty()); + + rows.update({"second.txt"}); + + EXPECT_EQ(rows.rows(), (Rows{"second.txt"})); + } + + TEST(InFlightRowsTests, LocalPathsCanBeKeys) + { + const auto first = *Paths::LocalPath::fromUtf8("/home/user/project/first.txt", Paths::LocalPlatform::Posix); + const auto second = *Paths::LocalPath::fromUtf8("/home/user/project/second.txt", Paths::LocalPlatform::Posix); + const auto third = *Paths::LocalPath::fromUtf8("/home/user/project/third.txt", Paths::LocalPlatform::Posix); + InFlightRows rows; + rows.update({first, second}); + rows.update({second, third}); + + EXPECT_EQ(rows.rowOf(third), std::optional{0}); + EXPECT_EQ(rows.rowOf(second), std::optional{1}); + EXPECT_EQ(rows.rowOf(first), std::nullopt); + EXPECT_EQ(rows.size(), std::size_t{2}); + } +} From 5ee2e23287135d9036ef40755281ad554fa3e305 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 061/114] Rendered the watch header with kind, status badge, actions and paths --- .../file_tracking/watch_header.cpp | 267 +++++++++++++++++- 1 file changed, 263 insertions(+), 4 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp index 4f167fad..da69dd46 100644 --- a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp +++ b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp @@ -1,15 +1,274 @@ #include +#include +#include +#include +#include -#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include namespace FileTracking { + namespace + { + ScriptNuiComponents::StyleVariant statusVariantOf(WatchStatus status) + { + switch (status) + { + case WatchStatus::Syncing: + return ScriptNuiComponents::StyleVariant::Primary; + case WatchStatus::Conflicts: + return ScriptNuiComponents::StyleVariant::Warning; + case WatchStatus::Failed: + return ScriptNuiComponents::StyleVariant::Danger; + case WatchStatus::Watching: + case WatchStatus::Stopped: + break; + } + return ScriptNuiComponents::StyleVariant::Regular; + } + + /** + * @brief A small header button (data-ft-action=@p actionName) that calls @p onActivate. + */ + Nui::ElementRenderer actionButton( + std::string text, + std::string actionName, + ScriptNuiComponents::StyleVariant styleVariant, + std::function onActivate + ) + { + using namespace Nui::Attributes; + + return ScriptNuiComponents::button({ + .text = std::move(text), + .attributes = + { + "data-ft-action"_attr = std::move(actionName), + onClick = + [onActivate = std::move(onActivate)](Nui::val) + { + onActivate(); + }, + }, + .styleVariant = styleVariant, + }); + } + + /** + * @brief Open Local Item, Reveal on Server and Stop for a running watch, Open Local Item and Remove for a + * stopped one. + */ + Nui::ElementRenderer + headerButtons(TrackingActions const* actions, std::shared_ptr const& item, bool stopped) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + auto openLocal = actionButton( + language->get("fileTracking", "openLocalItem"), + "open-local", + ScriptNuiComponents::StyleVariant::Regular, + [actions, item]() + { + if (actions->openLocally) + actions->openLocally(item->entry.value().localPath); + } + ); + + if (stopped) + { + // clang-format off + return div{class_ = "file-tracking-detail-buttons"}( + std::move(openLocal), + actionButton( + language->get("fileTracking", "removeButton"), + "remove", + ScriptNuiComponents::StyleVariant::Regular, + [actions, item]() + { + if (actions->removeStopped) + actions->removeStopped(item->watchId); + } + ) + ); + // clang-format on + } + + // clang-format off + return div{class_ = "file-tracking-detail-buttons"}( + std::move(openLocal), + actionButton( + language->get("fileTracking", "revealOnServer"), + "reveal-on-server", + ScriptNuiComponents::StyleVariant::Regular, + [actions, item]() + { + if (actions->revealOnServer) + actions->revealOnServer(item->entry.value().remotePath); + } + ), + actionButton( + language->get("fileTracking", "stopButton"), + "stop", + ScriptNuiComponents::StyleVariant::Danger, + [actions, item]() + { + if (actions->stop) + actions->stop(item->watchId); + } + ) + ); + // clang-format on + } + } + Nui::ElementRenderer watchHeader( [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item + TrackingActions const& actions, + std::shared_ptr const& item ) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + using Nui::Elements::span; + + if (!item) + return Nui::nil(); + + TrackingActions const* actionsPointer = &actions; + const auto openAndWatch = language->get("fileExplorer", "contextMenu", "openAndWatch"); + const auto uploadAndWatch = language->get("fileExplorer", "contextMenu", "uploadAndWatch"); + const auto temporaryCopy = language->get("fileTracking", "temporaryCopy"); + + const auto nameText = [item]() + { + return Nui::observe(item->name).generate( + [item]() + { + return item->name.value(); + } + ); + }; + const auto localText = [item, temporaryCopy]() + { + return Nui::observe(item->downloadedCopy, item->localPath) + .generate( + [item, temporaryCopy]() + { + return item->downloadedCopy.value() ? temporaryCopy : item->localPath.value(); + } + ); + }; + const auto localPathText = [item]() + { + return Nui::observe(item->localPath) + .generate( + [item]() + { + return item->localPath.value(); + } + ); + }; + const auto remoteText = [item]() + { + return Nui::observe(item->remotePath) + .generate( + [item]() + { + return item->remotePath.value(); + } + ); + }; + + // clang-format off + return header{class_ = "file-tracking-detail-header"}( + div{class_ = "file-tracking-detail-title-row"}( + div{class_ = "file-tracking-detail-identity"}( + span{ + class_ = "file-tracking-detail-kind-icon", + "aria-hidden"_attr = "true", + }( + Nui::observe(item->downloadedCopy), + [item]() -> Nui::ElementRenderer + { + return item->downloadedCopy.value() ? Ui5Icons::synchronize() : Ui5Icons::upload(); + } + ), + span{ + class_ = "file-tracking-detail-name", + Nui::Attributes::title = nameText(), + }( + text{nameText()}() + ), + span{class_ = "file-tracking-detail-kind"}( + text{Nui::observe(item->downloadedCopy).generate( + [item, openAndWatch, uploadAndWatch]() + { + return item->downloadedCopy.value() ? openAndWatch : uploadAndWatch; + } + )}() + ), + ScriptNuiComponents::badge({ + .text = Nui::observe(item->statusText).generate( + [item]() + { + return item->statusText.value(); + } + ), + .styleVariant = Nui::observe(item->status).generate( + [item](WatchStatus status) + { + return statusVariantOf(status); + } + ), + .attributes = { + "data-ft-status"_attr = Nui::observe(item->status).generate( + [item]() + { + return Utility::enumToString(item->status.value()); + } + ), + }, + }) + ), + div{class_ = "file-tracking-detail-actions"}( + Nui::observe(item->stop), + [actionsPointer, item]() -> Nui::ElementRenderer + { + return headerButtons(actionsPointer, item, item->stop.value().has_value()); + } + ) + ), + div{class_ = "file-tracking-detail-paths"}( + span{ + class_ = "file-tracking-detail-local", + Nui::Attributes::title = localPathText(), + }( + text{localText()}() + ), + span{ + class_ = "file-tracking-detail-remote", + Nui::Attributes::title = remoteText(), + }( + text{remoteText()}() + ) + ) + ); + // clang-format on } } From 0bea314dcca9f12b25448af7f91501e2c83b8287 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 062/114] Rendered the stop reason strip with Show and Open Again actions --- .../file_tracking/stop_reason_strip.cpp | 139 +++++++++++++++++- 1 file changed, 132 insertions(+), 7 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp index 7c752e46..ae41938f 100644 --- a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp +++ b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp @@ -1,15 +1,140 @@ #include +#include +#include +#include +#include +#include -#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include namespace FileTracking { - Nui::ElementRenderer stopReasonStrip( - [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item - ) + namespace + { + /** + * @brief The name of the watch an overlap stop names: as the tracker sent it, else as the panel knows it. + */ + std::string otherWatchNameOf(TrackingStore const& store, SharedData::WatchStop const& stop) + { + if (stop.otherWatchName) + return *stop.otherWatchName; + if (!stop.otherWatchId) + return {}; + if (auto const* entry = store.model().entryOf(*stop.otherWatchId); entry != nullptr) + return entry->name; + return {}; + } + + /** + * @brief Show “Y” for an overlap stop that names the other watch, Open Again for any other stopped copy, + * nothing otherwise. + */ + Nui::ElementRenderer + stopActions(TrackingStore const& store, TrackingActions const* actions, std::shared_ptr const& item) + { + using namespace Nui::Attributes; + + auto const& stop = item->stop.value(); + if (!stop) + return Nui::nil(); + + if (stop->reason == SharedData::WatchStopReason::Overlap && stop->otherWatchId) + { + return ScriptNuiComponents::button({ + .text = fmt::format( + fmt::runtime(language->get("fileTracking", "showOtherWatch")), otherWatchNameOf(store, *stop) + ), + .attributes = + { + "data-ft-action"_attr = "show-other-watch", + onClick = + [actions, otherWatchId = *stop->otherWatchId](Nui::val) + { + if (actions->showWatch) + actions->showWatch(otherWatchId); + }, + }, + }); + } + + if (!item->downloadedCopy.value()) + return Nui::nil(); + + return ScriptNuiComponents::button({ + .text = language->get("fileTracking", "openAgain"), + .attributes = + { + "data-ft-action"_attr = "open-again", + onClick = + [actions, item](Nui::val) + { + if (actions->openAgain) + actions->openAgain(item->watchId); + }, + }, + .styleVariant = ScriptNuiComponents::StyleVariant::Primary, + }); + } + } + + Nui::ElementRenderer + stopReasonStrip(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + if (!item) + return Nui::nil(); + + TrackingStore const* storePointer = &store; + TrackingActions const* actionsPointer = &actions; + + // clang-format off + return ScriptNuiComponents::messageStrip({ + .text = Nui::observe(item->stopText).generate( + [item]() + { + return item->stopText.value(); + } + ), + .styleVariant = ScriptNuiComponents::StyleVariant::Regular, + .icon = statusGlyph(WatchStatus::Stopped), + .actions = div{class_ = "file-tracking-stop-actions"}( + Nui::observe(item->stop, item->downloadedCopy), + [storePointer, actionsPointer, item]() -> Nui::ElementRenderer + { + return stopActions(*storePointer, actionsPointer, item); + } + ), + .attributes = { + "data-class"_attr = "file-tracking-stop-strip", + "data-ft-reason"_attr = Nui::observe(item->stop).generate( + [item]() + { + auto const& stop = item->stop.value(); + return stop ? Utility::enumToString(stop->reason) : std::string{}; + } + ), + hidden = Nui::observe(item->stop).generate( + [item]() + { + return !item->stop.value().has_value(); + } + ), + }, + }); + // clang-format on } } From fe804220fe4cfe37432fe9aef7ffbd9f2dc38d65 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 063/114] Rendered the pinned sync progress block with stable in-flight rows --- .../file_tracking/sync_progress_block.cpp | 410 +++++++++++++++++- 1 file changed, 406 insertions(+), 4 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp index 1971ff8b..709d52f0 100644 --- a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -1,15 +1,417 @@ #include +#include +#include +#include +#include -#include +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include namespace FileTracking { + namespace + { + /** + * @brief The progress texts that are not part of FileTrackingTexts. + */ + struct ProgressTexts + { + /** @brief "{} of {} files" (done, done and pending). */ + std::string files{}; + /** @brief "{} waiting" (not started yet). */ + std::string waiting{}; + /** @brief "{} waiting for your answer". */ + std::string waitingForAnswer{}; + /** @brief "Up to date · last upload {}" (clock time). */ + std::string upToDateLastUpload{}; + /** @brief "Stopped · last upload {}" (clock time). */ + std::string stoppedLastUpload{}; + /** @brief "One change waits in Conflicts". */ + std::string conflictsWaitOne{}; + /** @brief "{} changes wait in Conflicts". */ + std::string conflictsWait{}; + /** @brief "{} of {} files up to date". */ + std::string filesUpToDate{}; + }; + + ProgressTexts translatedProgressTexts() + { + return ProgressTexts{ + .files = language->get("fileTracking", "progressFiles"), + .waiting = language->get("fileTracking", "progressWaiting"), + .waitingForAnswer = language->get("fileTracking", "progressWaitingForAnswer"), + .upToDateLastUpload = language->get("fileTracking", "progressUpToDateLastUpload"), + .stoppedLastUpload = language->get("fileTracking", "progressStoppedLastUpload"), + .conflictsWaitOne = language->get("fileTracking", "progressConflictsWaitOne"), + .conflictsWait = language->get("fileTracking", "progressConflictsWait"), + .filesUpToDate = language->get("fileTracking", "progressFilesUpToDate"), + }; + } + + struct BarValues + { + std::uint64_t current{0u}; + std::uint64_t maximum{0u}; + }; + + /** + * @brief The line the block shows while nothing uploads. + */ + struct Summary + { + std::string text{}; + /** @brief The value of data-tone: "conflicts", "failed" or empty. */ + std::string tone{}; + /** @brief The bar beside the line, none for no bar. */ + std::optional bar{}; + }; + + /** + * @brief Whether anything of the watch is on its way, other than changes that wait for a conflict answer. + */ + bool isSyncing(SharedData::TrackedEntry const& entry, SharedData::WatchProgress const& progress) + { + if (entry.stop) + return false; + return entry.uploading || !progress.inFlight.empty() || + progress.changesPending > progress.waitingForAnswer; + } + + template + std::string formatted(std::string const& pattern, ArgumentsT&&... arguments) + { + return fmt::format(fmt::runtime(pattern), std::forward(arguments)...); + } + + Summary stoppedSummary( + ProgressTexts const& texts, + FileTrackingTexts const& storeTexts, + SharedData::WatchProgress const& progress + ) + { + if (!progress.lastSync) + return {.text = storeTexts.statuses.stopped}; + return {.text = formatted(texts.stoppedLastUpload, localClockTime(progress.lastSync->endedAt))}; + } + + Summary answerSummary(ProgressTexts const& texts, SharedData::WatchProgress const& progress) + { + if (progress.changesDone > 0u) + { + const auto total = progress.changesDone + progress.changesPending; + return { + .text = formatted(texts.filesUpToDate, progress.changesDone, total), + .tone = "conflicts", + .bar = BarValues{.current = progress.changesDone, .maximum = total}, + }; + } + if (progress.waitingForAnswer == 1u) + return {.text = texts.conflictsWaitOne, .tone = "conflicts"}; + return {.text = formatted(texts.conflictsWait, progress.waitingForAnswer), .tone = "conflicts"}; + } + + Summary failureSummary( + FileTrackingTexts const& storeTexts, + ProgressTexts const& texts, + SharedData::WatchProgress const& progress, + std::uint64_t failureCount + ) + { + if (!progress.lastSync || progress.lastSync->changes == 0u) + return {.text = failedCountText(storeTexts, failureCount), .tone = "failed"}; + const auto changes = progress.lastSync->changes; + const auto upToDate = changes - std::min(failureCount, changes); + return { + .text = formatted(texts.filesUpToDate, upToDate, changes), + .tone = "failed", + .bar = BarValues{.current = upToDate, .maximum = changes}, + }; + } + + Summary idleSummary( + FileTrackingTexts const& storeTexts, + ProgressTexts const& texts, + SharedData::TrackedEntry const& entry, + SharedData::WatchProgress const& progress, + std::uint64_t failureCount + ) + { + if (entry.stop) + return stoppedSummary(texts, storeTexts, progress); + if (progress.waitingForAnswer > 0u) + return answerSummary(texts, progress); + if (failureCount > 0u) + return failureSummary(storeTexts, texts, progress, failureCount); + if (!progress.lastSync) + return {.text = storeTexts.statuses.upToDate}; + return { + .text = formatted(texts.upToDateLastUpload, localClockTime(progress.lastSync->endedAt)), + .bar = BarValues{.current = progress.lastSync->changes, .maximum = progress.lastSync->changes}, + }; + } + + /** + * @brief A counts bar that never changes. The summary renders it anew whenever its values change, so its + * observed values only need to live while it renders. + */ + Nui::ElementRenderer fixedCountBar(BarValues values) + { + const auto current = std::make_shared>(static_cast(values.current)); + const auto maximum = std::make_shared>(static_cast(values.maximum)); + auto bar = ScriptNuiComponents::progressBar({ + .current = current, + .maximum = maximum, + .textMode = ScriptNuiComponents::ProgressBarTextMode::Counts, + .colorMode = ScriptNuiComponents::ProgressColorMode::Hue, + .zeroAsComplete = true, + .shine = false, + }); + return [bar = std::move(bar), current, maximum](Nui::Dom::Element& parent, Nui::Renderer const& renderer) + { + return bar(parent, renderer); + }; + } + + /** + * @brief One row of the files in flight: the path below the watched item and its bar. A free row keeps its + * place (data-free, hidden by CSS), so the rows below it do not move. + */ + Nui::ElementRenderer inFlightRow(std::shared_ptr const& row) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + using Nui::Elements::span; + + const auto nameText = [row]() + { + return Nui::observe(row->name).generate( + [row]() + { + return row->name.value(); + } + ); + }; + + // clang-format off + return div{ + class_ = "file-tracking-sync-line file-tracking-sync-file", + "data-free"_attr = Nui::observe(row->path).generate( + [row]() + { + return row->path.value().empty(); + } + ), + }( + span{ + class_ = "file-tracking-sync-file-name", + Nui::Attributes::title = nameText(), + }( + text{nameText()}() + ), + ScriptNuiComponents::progressBar({ + .current = row->bytes, + .maximum = row->totalBytes, + .textMode = ScriptNuiComponents::ProgressBarTextMode::Bytes, + .colorMode = ScriptNuiComponents::ProgressColorMode::Hue, + .attributes = { + "data-ft-progress-file"_attr = Nui::observe(row->path).generate( + [row]() + { + return row->path.value(); + } + ), + }, + }) + ); + // clang-format on + } + + /** + * @brief "12 of 48 files · 34 waiting", plus the changes that wait for an answer. + */ + Nui::ElementRenderer + runningSummary(std::shared_ptr const& texts, std::shared_ptr const& item) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::span; + + // clang-format off + return span{class_ = "file-tracking-sync-summary"}( + span{class_ = "file-tracking-sync-done"}( + text{Nui::observe(item->progress).generate( + [texts, item]() + { + auto const& progress = item->progress.value(); + return formatted( + texts->files, progress.changesDone, progress.changesDone + progress.changesPending + ); + } + )}() + ), + span{ + class_ = "file-tracking-sync-waiting", + hidden = Nui::observe(item->progress).generate( + [item]() + { + return item->progress.value().changesWaiting == 0u; + } + ), + }( + text{Nui::observe(item->progress).generate( + [texts, item]() + { + return formatted(texts->waiting, item->progress.value().changesWaiting); + } + )}() + ), + span{ + class_ = "file-tracking-sync-answers", + hidden = Nui::observe(item->progress).generate( + [item]() + { + return item->progress.value().waitingForAnswer == 0u; + } + ), + }( + text{Nui::observe(item->progress).generate( + [texts, item]() + { + return formatted(texts->waitingForAnswer, item->progress.value().waitingForAnswer); + } + )}() + ) + ); + // clang-format on + } + + /** + * @brief What the block shows while uploading. It stays mounted (hidden otherwise), so its bars update in + * place and its file rows keep their order. + */ + Nui::ElementRenderer + runningPart(std::shared_ptr const& texts, std::shared_ptr const& item) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + // clang-format off + return div{ + class_ = "file-tracking-sync-running", + hidden = Nui::observe(item->entry, item->progress).generate( + [item]() + { + return !isSyncing(item->entry.value(), item->progress.value()); + } + ), + }( + div{class_ = "file-tracking-sync-line file-tracking-sync-total"}( + runningSummary(texts, item), + ScriptNuiComponents::progressBar({ + .current = item->bytesDone, + .maximum = item->bytesTotal, + .textMode = ScriptNuiComponents::ProgressBarTextMode::Bytes, + .colorMode = ScriptNuiComponents::ProgressColorMode::Hue, + .attributes = { + "data-ft-progress-total"_attr = true, + }, + }) + ), + div{class_ = "file-tracking-sync-files"}( + Nui::range(item->inFlight), + [item](long long, std::shared_ptr const& row) -> Nui::ElementRenderer + { + return inFlightRow(row); + } + ) + ); + // clang-format on + } + + /** + * @brief The summary line and the bar of the last sync while nothing uploads, nothing while uploading. + */ + Nui::ElementRenderer idlePart( + TrackingStore const* store, + std::shared_ptr const& texts, + std::shared_ptr const& item + ) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + using Nui::Elements::span; + + // clang-format off + return div{class_ = "file-tracking-sync-idle"}( + Nui::observe(item->entry, item->progress, item->failureCount), + [store, texts, item]() -> Nui::ElementRenderer + { + auto const& entry = item->entry.value(); + auto const& progress = item->progress.value(); + if (isSyncing(entry, progress)) + return Nui::nil(); + + const auto summary = idleSummary( + store->texts(), *texts, entry, progress, item->failureCount->value() + ); + return div{ + class_ = "file-tracking-sync-line", + "data-tone"_attr = summary.tone, + }( + span{ + class_ = "file-tracking-sync-summary", + Nui::Attributes::title = summary.text, + }( + text{summary.text}() + ), + summary.bar ? fixedCountBar(*summary.bar) : Nui::nil() + ); + } + ); + // clang-format on + } + } + Nui::ElementRenderer syncProgressBlock( - [[maybe_unused]] TrackingStore& store, + TrackingStore& store, [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item + std::shared_ptr const& item ) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + if (!item) + return Nui::nil(); + + const auto texts = std::make_shared(translatedProgressTexts()); + + // clang-format off + return div{ + class_ = "file-tracking-sync", + role = "group", + "aria-label"_attr = language->get("fileTracking", "progressLabel"), + }( + runningPart(texts, item), + idlePart(&store, texts, item) + ); + // clang-format on } } From cef6fc142e659913bee34ef153b0652fa77b8d2a Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 064/114] Composed the watch detail and rendered the orphaned copy detail --- .../file_tracking/orphan_detail.cpp | 150 +++++++++++++++++- .../file_tracking/watch_detail.cpp | 36 ++++- 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp index 32291d40..e9039989 100644 --- a/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp +++ b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp @@ -1,15 +1,157 @@ #include +#include +#include +#include +#include -#include +#include +#include + +#include +#include +#include + +#include + +#include namespace FileTracking { + namespace + { + Nui::ElementRenderer fact(std::string line) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + return div{class_ = "file-tracking-orphan-fact"}(text{std::move(line)}()); + } + + /** + * @brief Where the copy came from, when it was downloaded and when its watch ended, and its size. + */ + Nui::ElementRenderer orphanFacts(SharedData::OrphanedCopy const& copy) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto origin = copy.remotePath + ? fmt::format(fmt::runtime(language->get("fileTracking", "orphanDownloadedFrom")), copy.remotePath->value()) + : language->get("fileTracking", "orphanOriginUnknown"); + + // clang-format off + return div{class_ = "file-tracking-orphan-fact-list"}( + fact(origin), + copy.downloadedAt + ? fact(fmt::format( + fmt::runtime(language->get("fileTracking", "orphanDownloadedAt")), + localDateTime(*copy.downloadedAt) + )) + : Nui::nil(), + copy.endedAt + ? fact(fmt::format( + fmt::runtime(language->get("fileTracking", "orphanEndedAt")), + localDateTime(*copy.endedAt) + )) + : Nui::nil(), + fact(fmt::format(fmt::runtime(language->get("fileTracking", "orphanSize")), sizeText(copy.sizeBytes))) + ); + // clang-format on + } + } + Nui::ElementRenderer orphanDetail( [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item + TrackingActions const& actions, + std::shared_ptr const& item ) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + using Nui::Elements::span; + + if (!item) + return Nui::nil(); + + TrackingActions const* actionsPointer = &actions; + const auto nameText = [item]() + { + return Nui::observe(item->name).generate( + [item]() + { + return item->name.value(); + } + ); + }; + + // clang-format off + return div{ + class_ = "file-tracking-detail", + "data-ft-detail"_attr = "orphan", + "data-ft-instance-id"_attr = item->instanceId.value(), + }( + header{class_ = "file-tracking-detail-header"}( + div{class_ = "file-tracking-detail-title-row"}( + div{class_ = "file-tracking-detail-identity"}( + span{ + class_ = "file-tracking-detail-kind-icon", + "aria-hidden"_attr = "true", + }( + orphanGlyph() + ), + span{ + class_ = "file-tracking-detail-name", + Nui::Attributes::title = nameText(), + }( + text{nameText()}() + ), + span{class_ = "file-tracking-detail-kind"}( + text{language->get("fileTracking", "orphanKind")}() + ) + ), + div{class_ = "file-tracking-detail-actions"}( + div{class_ = "file-tracking-detail-buttons"}( + ScriptNuiComponents::button({ + .text = language->get("fileTracking", "showFolder"), + .attributes = { + "data-ft-action"_attr = "show-folder", + onClick = [actionsPointer, item](Nui::val) + { + if (actionsPointer->openLocally) + actionsPointer->openLocally(item->copy.value().directory); + }, + }, + }), + ScriptNuiComponents::button({ + .text = language->get("fileTracking", "deleteCopy"), + .attributes = { + "data-ft-action"_attr = "delete-orphan", + onClick = [actionsPointer, item](Nui::val) + { + if (actionsPointer->deleteOrphanedCopy) + actionsPointer->deleteOrphanedCopy(item->instanceId); + }, + }, + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + }) + ) + ) + ) + ), + div{class_ = "file-tracking-orphan-facts"}( + Nui::observe(item->copy), + [item]() -> Nui::ElementRenderer + { + return orphanFacts(item->copy.value()); + } + ), + p{class_ = "file-tracking-orphan-explanation"}( + text{language->get("fileTracking", "orphanExplanation")}() + ) + ); + // clang-format on } } diff --git a/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp index 696ec542..4f35eb0c 100644 --- a/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp +++ b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp @@ -1,15 +1,37 @@ #include +#include +#include +#include +#include -#include +#include +#include namespace FileTracking { - Nui::ElementRenderer watchDetail( - [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item - ) + Nui::ElementRenderer + watchDetail(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + if (!item) + return Nui::nil(); + + // clang-format off + return div{ + class_ = "file-tracking-detail", + "data-ft-detail"_attr = "watch", + "data-ft-watch-id"_attr = item->watchId.value(), + }( + watchHeader(store, actions, item), + stopReasonStrip(store, actions, item), + syncProgressBlock(store, actions, item), + div{class_ = "file-tracking-detail-tabs-area"}( + detailTabs(store, actions, item) + ) + ); + // clang-format on } } From 35a7c21415a6ac7046268e211a9489fafe13aea2 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:40:02 +0200 Subject: [PATCH 065/114] Styled the file tracking detail pane, stop strip, progress block and orphan detail --- static/styles/file_tracking_detail.css | 334 +++++++++++++++++++++++++ 1 file changed, 334 insertions(+) diff --git a/static/styles/file_tracking_detail.css b/static/styles/file_tracking_detail.css index e69de29b..cfa5843f 100644 --- a/static/styles/file_tracking_detail.css +++ b/static/styles/file_tracking_detail.css @@ -0,0 +1,334 @@ +.file-tracking-detail { + --file-tracking-detail-line: var(--border-color-fields); + --file-tracking-detail-surface: color-mix(in srgb, var(--color) 3%, transparent); + --file-tracking-detail-muted-surface: color-mix(in srgb, var(--color) 9%, transparent); + --file-tracking-detail-warning: var(--script-nui-components-toast-warning, #e0a02e); + --file-tracking-detail-danger: var(--script-nui-components-toast-error, #d9534f); + --file-tracking-detail-theme-text: color-mix(in srgb, var(--theme-color) 75%, var(--color) 25%); + --file-tracking-detail-warning-text: color-mix(in srgb, var(--file-tracking-detail-warning) 75%, var(--color) 25%); + --file-tracking-detail-danger-text: color-mix(in srgb, var(--file-tracking-detail-danger) 75%, var(--color) 25%); + --file-tracking-detail-total-bar-height: 16px; + --file-tracking-detail-file-bar-height: 11px; + + box-sizing: border-box; + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + height: 100%; + min-width: 0; + min-height: 0; + padding: 10px 14px; + overflow-x: hidden; + overflow-y: auto; + color: var(--color); + font-size: 0.8125rem; + line-height: 1.35; + + &>* { + flex-shrink: 0; + min-width: 0; + } +} + +.file-tracking-detail-tabs-area { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 8rem; + + &>* { + flex: 1 1 auto; + min-height: 0; + } +} + +.file-tracking-detail-header { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.file-tracking-detail-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; +} + +.file-tracking-detail-identity { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 14rem; + min-width: 0; + + &>.script-nui-badge[data-ft-status] { + --script-nui-components-badge-height: 20px; + --script-nui-components-badge-font-size: 0.72rem; + --script-nui-components-badge-font-weight: 500; + + padding: 0 8px; + background-color: var(--file-tracking-detail-muted-surface); + color: var(--color-secondary); + } + + &>.script-nui-badge[data-ft-status="Watching"] { + background-color: transparent; + box-shadow: inset 0 0 0 1px var(--file-tracking-detail-line); + } + + &>.script-nui-badge[data-ft-status="Syncing"] { + background-color: color-mix(in srgb, var(--theme-color) 20%, var(--brightness-mixin) 80%); + color: var(--file-tracking-detail-theme-text); + } + + &>.script-nui-badge[data-ft-status="Conflicts"] { + background-color: color-mix(in srgb, var(--file-tracking-detail-warning) 20%, var(--brightness-mixin) 80%); + color: var(--file-tracking-detail-warning-text); + } + + &>.script-nui-badge[data-ft-status="Failed"] { + background-color: color-mix(in srgb, var(--file-tracking-detail-danger) 18%, var(--brightness-mixin) 82%); + color: var(--file-tracking-detail-danger-text); + } +} + +.file-tracking-detail-kind-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 4px; + background: color-mix(in srgb, var(--color) 8%, transparent); + color: var(--color); + + & svg { + width: 14px; + height: 14px; + fill: currentColor; + } + + &>.file-tracking-glyph { + display: inline-flex; + } +} + +.file-tracking-detail-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9375rem; + font-weight: 700; +} + +.file-tracking-detail-kind { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + height: 18px; + padding: 0 6px; + border: 1px solid var(--file-tracking-detail-line); + border-radius: 3px; + color: var(--color-secondary); + font-size: 0.6875rem; + white-space: nowrap; +} + +.file-tracking-detail-actions { + display: flex; + flex: 0 0 auto; + margin-left: auto; +} + +.file-tracking-detail-buttons, +.file-tracking-stop-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + + &>.script-nui-button { + height: 24px; + padding: 0 8px; + font-size: 0.75rem; + white-space: nowrap; + } +} + +.file-tracking-detail-paths { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-secondary); + font-size: 0.78rem; +} + +.file-tracking-detail-remote::before { + content: "\2192"; + margin: 0 0.4em; +} + +.script-nui-message-strip[data-class="file-tracking-stop-strip"] { + --script-nui-message-strip-regular-bg: color-mix(in srgb, var(--color) 6%, transparent); + --script-nui-message-strip-regular-border-color: var(--file-tracking-detail-line); + --script-nui-message-strip-regular-accent-color: var(--file-tracking-detail-line); + --script-nui-message-strip-regular-text-color: var(--color); + --script-nui-message-strip-regular-icon-color: var(--color-secondary); + + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--file-tracking-detail-line); + border-radius: 4px; + font-size: 0.78rem; + line-height: 1.35; + + &[hidden] { + display: none; + } + + &>.file-tracking-glyph { + display: inline-flex; + flex: 0 0 auto; + + & svg { + width: 14px; + height: 14px; + fill: currentColor; + } + } + + &>.script-nui-message-strip-actions:has(> .file-tracking-stop-actions:empty) { + display: none; + } +} + +.file-tracking-sync { + --script-nui-components-progress-bar-height: var(--file-tracking-detail-total-bar-height); + --script-nui-components-progress-bar-radius: 8px; + --script-nui-components-progress-bar-font: Inconsolata, Hack, JetBrains Mono, Terminus, Fixed, monospace; + --script-nui-components-progress-bar-font-size: 11px; + --script-nui-components-progress-bar-text-color: rgba(255, 255, 255, 0.9); + --script-nui-components-progress-bar-text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + + container: file-tracking-sync / inline-size; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border: 1px solid var(--file-tracking-detail-line); + border-radius: 4px; + background: var(--file-tracking-detail-surface); + font-size: 0.75rem; +} + +.file-tracking-sync-running, +.file-tracking-sync-files { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.file-tracking-sync-running[hidden], +.file-tracking-sync-files:empty, +.file-tracking-sync-idle:empty { + display: none; +} + +.file-tracking-sync-line { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); + align-items: center; + gap: 2px 12px; + min-width: 0; + min-height: var(--file-tracking-detail-total-bar-height); +} + +.file-tracking-sync-summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.file-tracking-sync-waiting, +.file-tracking-sync-answers { + color: var(--color-secondary); + + &::before { + content: "\00B7"; + margin: 0 0.35em; + } +} + +.file-tracking-sync-answers { + color: var(--file-tracking-detail-warning-text); +} + +.file-tracking-sync-file { + --script-nui-components-progress-bar-height: var(--file-tracking-detail-file-bar-height); + --script-nui-components-progress-bar-radius: 6px; + --script-nui-components-progress-bar-font-size: 10px; + + &[data-free] { + visibility: hidden; + } +} + +.file-tracking-sync-file-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-secondary); +} + +.file-tracking-sync-idle .file-tracking-sync-summary { + color: var(--color-secondary); +} + +.file-tracking-sync-idle .file-tracking-sync-line[data-tone="conflicts"]>.file-tracking-sync-summary { + color: var(--file-tracking-detail-warning-text); +} + +.file-tracking-sync-idle .file-tracking-sync-line[data-tone="failed"]>.file-tracking-sync-summary { + color: var(--file-tracking-detail-danger-text); +} + +@container file-tracking-sync (width < 22rem) { + .file-tracking-sync-line { + grid-template-columns: minmax(0, 1fr); + } +} + +.file-tracking-orphan-facts { + padding: 8px 10px; + border: 1px solid var(--file-tracking-detail-line); + border-radius: 4px; + background: var(--file-tracking-detail-surface); + font-size: 0.78rem; +} + +.file-tracking-orphan-fact-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.file-tracking-orphan-fact { + overflow-wrap: anywhere; +} + +.file-tracking-orphan-explanation { + margin: 0; + color: var(--color-secondary); + font-size: 0.78rem; +} From 17311b32d910a2710fb0a37190c2d31e2c51c78d Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:41:01 +0200 Subject: [PATCH 066/114] Formatted the detail pane views with clang-format --- .../file_tracking/orphan_detail.cpp | 17 ++++++++++------- .../file_tracking/stop_reason_strip.cpp | 14 ++++++-------- .../file_tracking/sync_progress_block.cpp | 9 +++++---- .../file_tracking/watch_header.cpp | 13 +++++++------ 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp index e9039989..ae82e99f 100644 --- a/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp +++ b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp @@ -38,7 +38,9 @@ namespace FileTracking using Nui::Elements::div; const auto origin = copy.remotePath - ? fmt::format(fmt::runtime(language->get("fileTracking", "orphanDownloadedFrom")), copy.remotePath->value()) + ? fmt::format( + fmt::runtime(language->get("fileTracking", "orphanDownloadedFrom")), copy.remotePath->value() + ) : language->get("fileTracking", "orphanOriginUnknown"); // clang-format off @@ -79,12 +81,13 @@ namespace FileTracking TrackingActions const* actionsPointer = &actions; const auto nameText = [item]() { - return Nui::observe(item->name).generate( - [item]() - { - return item->name.value(); - } - ); + return Nui::observe(item->name) + .generate( + [item]() + { + return item->name.value(); + } + ); }; // clang-format off diff --git a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp index ae41938f..bd05a290 100644 --- a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp +++ b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp @@ -55,16 +55,14 @@ namespace FileTracking .text = fmt::format( fmt::runtime(language->get("fileTracking", "showOtherWatch")), otherWatchNameOf(store, *stop) ), - .attributes = + .attributes = { + "data-ft-action"_attr = "show-other-watch", + onClick = [actions, otherWatchId = *stop->otherWatchId](Nui::val) { - "data-ft-action"_attr = "show-other-watch", - onClick = - [actions, otherWatchId = *stop->otherWatchId](Nui::val) - { - if (actions->showWatch) - actions->showWatch(otherWatchId); - }, + if (actions->showWatch) + actions->showWatch(otherWatchId); }, + }, }); } diff --git a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp index 709d52f0..b3d9d36a 100644 --- a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -86,8 +86,7 @@ namespace FileTracking { if (entry.stop) return false; - return entry.uploading || !progress.inFlight.empty() || - progress.changesPending > progress.waitingForAnswer; + return entry.uploading || !progress.inFlight.empty() || progress.changesPending > progress.waitingForAnswer; } template @@ -169,8 +168,10 @@ namespace FileTracking */ Nui::ElementRenderer fixedCountBar(BarValues values) { - const auto current = std::make_shared>(static_cast(values.current)); - const auto maximum = std::make_shared>(static_cast(values.maximum)); + const auto current = + std::make_shared>(static_cast(values.current)); + const auto maximum = + std::make_shared>(static_cast(values.maximum)); auto bar = ScriptNuiComponents::progressBar({ .current = current, .maximum = maximum, diff --git a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp index da69dd46..864bb26b 100644 --- a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp +++ b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp @@ -157,12 +157,13 @@ namespace FileTracking const auto nameText = [item]() { - return Nui::observe(item->name).generate( - [item]() - { - return item->name.value(); - } - ); + return Nui::observe(item->name) + .generate( + [item]() + { + return item->name.value(); + } + ); }; const auto localText = [item, temporaryCopy]() { From 0438242d27a413440e6dd39d813150a61d5dd9b9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:41:32 +0200 Subject: [PATCH 067/114] Left out the last-sync bar when the last sync ended no change --- .../session_components/file_tracking/sync_progress_block.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp index b3d9d36a..541e30a1 100644 --- a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -156,9 +156,11 @@ namespace FileTracking return failureSummary(storeTexts, texts, progress, failureCount); if (!progress.lastSync) return {.text = storeTexts.statuses.upToDate}; + const auto changes = progress.lastSync->changes; return { .text = formatted(texts.upToDateLastUpload, localClockTime(progress.lastSync->endedAt)), - .bar = BarValues{.current = progress.lastSync->changes, .maximum = progress.lastSync->changes}, + .bar = changes > 0u ? std::optional{BarValues{.current = changes, .maximum = changes}} + : std::nullopt, }; } From 8727d25de9942f13f26c1f37eaffb0ebd2019f98 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:47 +0200 Subject: [PATCH 068/114] Implemented the file tracking item list box and its small button --- .../file_tracking/item_list_box.hpp | 31 ++++++-- .../file_tracking/item_list_box.cpp | 70 ++++++++++++++++++- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp index 4c0f8448..4f841f19 100644 --- a/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp +++ b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp @@ -1,9 +1,12 @@ #pragma once +#include + #include #include #include +#include #include #include #include @@ -30,11 +33,31 @@ namespace FileTracking /** * @brief A toolbar above a box that scrolls on its own, or an empty text instead of both. Markup: - * div.file-tracking-item-list-box[data-empty] > div.file-tracking-item-list-box-toolbar (actions, then - * span.file-tracking-item-list-box-hint), div.file-tracking-item-list-box-items (@p content) and - * div.file-tracking-item-list-box-empty (emptyText). Kept in the host until a second user (the queue's - * Failed tab) adopts it. + * div.file-tracking-item-list-box[data-empty] > div.file-tracking-item-list-box-toolbar + * (div.file-tracking-item-list-box-actions holding the actions, then span.file-tracking-item-list-box-hint), + * div.file-tracking-item-list-box-items (@p content) and div.file-tracking-item-list-box-empty (emptyText). + * Kept in the host until a second user (the queue's Failed tab) adopts it. * @param content The rows, usually an element holding a Nui::range. */ Nui::ElementRenderer itemListBox(ItemListBoxOptions options, Nui::ElementRenderer content); + + /** + * @brief The options of @ref itemListBoxButton, for designated initializers in this order. + */ + struct ItemListBoxButtonOptions + { + std::string text = {}; + std::optional icon = std::nullopt; + /** @brief The value of data-ft-action. */ + std::string action = {}; + ScriptNuiComponents::StyleVariant styleVariant = ScriptNuiComponents::StyleVariant::Regular; + std::function onPress = {}; + /** @brief Merged onto the button, for a title or an aria-label. */ + std::vector attributes = {}; + }; + + /** + * @brief A small library button for the toolbar or the rows of an @ref itemListBox. + */ + Nui::ElementRenderer itemListBoxButton(ItemListBoxButtonOptions options); } diff --git a/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp index 0e26bd63..141b5779 100644 --- a/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp +++ b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp @@ -1,12 +1,76 @@ #include +#include + +#include +#include +#include #include +#include + +#include namespace FileTracking { - Nui::ElementRenderer - itemListBox([[maybe_unused]] ItemListBoxOptions options, [[maybe_unused]] Nui::ElementRenderer content) + Nui::ElementRenderer itemListBox(ItemListBoxOptions options, Nui::ElementRenderer content) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + using Nui::Elements::span; + + std::vector rootAttributes{}; + rootAttributes.reserve(2u); + rootAttributes.push_back(class_ = "file-tracking-item-list-box"); + if (options.count) + { + rootAttributes.push_back( + "data-empty"_attr = Nui::observe(options.count) + .generate( + [](std::uint64_t count) + { + return count == 0u; + } + ) + ); + } + + // clang-format off + return div{Nui::mergeAttributes(std::move(rootAttributes), std::move(options.attributes))}( + div{class_ = "file-tracking-item-list-box-toolbar"}( + div{class_ = "file-tracking-item-list-box-actions"}( + options.actions ? std::move(*options.actions) : Nui::nil() + ), + span{class_ = "file-tracking-item-list-box-hint"}(std::move(options.hint)) + ), + div{class_ = "file-tracking-item-list-box-items"}( + std::move(content) + ), + div{class_ = "file-tracking-item-list-box-empty"}(std::move(options.emptyText)) + ); + // clang-format on + } + + Nui::ElementRenderer itemListBoxButton(ItemListBoxButtonOptions options) { - return Nui::nil(); + using namespace Nui::Attributes; + + std::vector attributes{}; + attributes.reserve(2u); + attributes.push_back("data-ft-action"_attr = std::move(options.action)); + attributes.push_back( + onClick = + [onPress = std::move(options.onPress)](Nui::val) + { + if (onPress) + onPress(); + } + ); + return ScriptNuiComponents::button({ + .text = std::move(options.text), + .icon = std::move(options.icon), + .attributes = Nui::mergeAttributes(std::move(attributes), std::move(options.attributes)), + .styleVariant = options.styleVariant, + }); } } From f639c2457696119dbf72f6e8c7cf931aaddda496 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 069/114] Implemented the Conflicts and Failed tabs of the watch detail --- .../file_tracking/conflicts_tab.cpp | 106 ++++++++++++- .../file_tracking/failures_tab.cpp | 139 +++++++++++++++++- 2 files changed, 237 insertions(+), 8 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp index aafee768..211c065e 100644 --- a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp @@ -1,15 +1,113 @@ #include +#include +#include +#include -#include +#include + +#include +#include +#include + +#include namespace FileTracking { + namespace + { + Nui::ElementRenderer conflictRow(TrackingActions const& actions, ConflictItem const& item) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Attributes::title; + using Nui::Elements::div; + + const auto conflictId = item.conflict.conflictId; + const auto answer = [&actions, conflictId](SharedData::ConflictResolution resolution) + { + return [&actions, conflictId, resolution]() + { + if (actions.answerConflict) + actions.answerConflict(conflictId, resolution); + }; + }; + + // clang-format off + return div{ + class_ = "file-tracking-item-row file-tracking-conflict-row", + role = "listitem", + "data-ft-conflict-id"_attr = conflictId.value(), + }( + div{class_ = "file-tracking-item-row-text"}( + div{class_ = "file-tracking-item-row-file", title = item.file}(item.file), + div{class_ = "file-tracking-item-row-detail", title = item.line}(item.line) + ), + div{class_ = "file-tracking-item-row-actions"}( + itemListBoxButton({ + .text = language->get("fileTracking", "replaceOnServer"), + .action = "replace", + .styleVariant = ScriptNuiComponents::StyleVariant::Warning, + .onPress = answer(SharedData::ConflictResolution::ReplaceOnServer), + }), + itemListBoxButton({ + .text = language->get("fileTracking", "keepServers"), + .action = "keep", + .onPress = answer(SharedData::ConflictResolution::KeepServers), + }) + ) + ); + // clang-format on + } + } + Nui::ElementRenderer conflictsTab( [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item + TrackingActions const& actions, + std::shared_ptr const& item ) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto answerAll = [&actions, watchId = item->watchId](SharedData::ConflictResolution resolution) + { + return [&actions, watchId, resolution]() + { + if (actions.answerAllConflicts) + actions.answerAllConflicts(watchId, resolution); + }; + }; + + // clang-format off + return itemListBox( + { + .actions = fragment( + itemListBoxButton({ + .text = language->get("fileTracking", "replaceAllOnServer"), + .action = "replace-all", + .styleVariant = ScriptNuiComponents::StyleVariant::Warning, + .onPress = answerAll(SharedData::ConflictResolution::ReplaceOnServer), + }), + itemListBoxButton({ + .text = language->get("fileTracking", "keepAllServers"), + .action = "keep-all", + .onPress = answerAll(SharedData::ConflictResolution::KeepServers), + }) + ), + .hint = language->get("fileTracking", "keepAsksAgain"), + .emptyText = language->get("fileTracking", "noConflicts"), + .count = item->conflictCount, + .attributes = {"data-class"_attr = "conflicts"}, + }, + div{class_ = "file-tracking-item-list", role = "list"}( + Nui::range(item->conflicts), + [&actions, keepAlive = item](long long, std::shared_ptr const& conflict) -> Nui::ElementRenderer + { + return conflictRow(actions, *conflict); + } + ) + ); + // clang-format on } } diff --git a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp index 1bbd9c1c..9b8b61ba 100644 --- a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp @@ -1,15 +1,146 @@ #include +#include +#include +#include +#include -#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include namespace FileTracking { + namespace + { + /** + * @return The tooltip of the reason: the system's or the server's detail and, after more than one attempt, how + * often it failed. Empty sets no tooltip. + */ + std::string reasonTooltip(SharedData::UploadFailure const& failure) + { + const auto detail = failure.detail.value_or(std::string{}); + if (failure.attempts <= 1u) + return detail; + const auto attempts = + fmt::format(fmt::runtime(language->get("fileTracking", "failedTimes")), failure.attempts); + if (detail.empty()) + return attempts; + return fmt::format("{}\n{}", detail, attempts); + } + + Nui::ElementRenderer failureRow(TrackingActions const& actions, FailureItem const& item) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Attributes::title; + using Nui::Elements::div; + using Nui::Elements::span; + + const auto failureId = item.failure.failureId; + + // clang-format off + return div{ + class_ = "file-tracking-item-row file-tracking-failure-row", + role = "listitem", + "data-ft-failure-id"_attr = failureId.value(), + "data-ft-reason"_attr = Utility::enumToString(item.failure.reason), + }( + div{class_ = "file-tracking-item-row-text"}( + div{class_ = "file-tracking-item-row-file", title = item.file}(item.file), + div{class_ = "file-tracking-item-row-detail", title = reasonTooltip(item.failure)}(item.reason) + ), + div{class_ = "file-tracking-item-row-actions"}( + span{class_ = "file-tracking-item-row-time"}(item.time), + itemListBoxButton({ + .text = language->get("fileTracking", "retry"), + .action = "retry", + .onPress = [&actions, failureId]() + { + if (actions.retryFailure) + actions.retryFailure(failureId); + }, + }), + itemListBoxButton({ + .icon = Ui5Icons::decline(), + .action = "dismiss", + .styleVariant = ScriptNuiComponents::StyleVariant::Transparent, + .onPress = [&actions, failureId]() + { + if (actions.dismissFailure) + actions.dismissFailure(failureId); + }, + .attributes = { + title = language->get("fileTracking", "dismiss"), + "aria-label"_attr = fmt::format( + fmt::runtime(language->get("fileTracking", "dismissFailureLabel")), item.file + ), + }, + }) + ) + ); + // clang-format on + } + } + Nui::ElementRenderer failuresTab( [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item + TrackingActions const& actions, + std::shared_ptr const& item ) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto watchId = item->watchId; + + // clang-format off + return itemListBox( + { + .actions = fragment( + itemListBoxButton({ + .text = language->get("fileTracking", "retryAll"), + .icon = Ui5Icons::refresh(), + .action = "retry-all", + .styleVariant = ScriptNuiComponents::StyleVariant::Primary, + .onPress = [&actions, watchId]() + { + if (actions.retryAllFailures) + actions.retryAllFailures(watchId); + }, + }), + itemListBoxButton({ + .text = language->get("fileTracking", "dismissAll"), + .action = "dismiss-all", + .onPress = [&actions, watchId]() + { + if (actions.dismissAllFailures) + actions.dismissAllFailures(watchId); + }, + }) + ), + .hint = language->get("fileTracking", "notRetriedAutomatically"), + .emptyText = language->get("fileTracking", "noFailures"), + .count = item->failureCount, + .attributes = {"data-class"_attr = "failures"}, + }, + div{class_ = "file-tracking-item-list", role = "list"}( + Nui::range(item->failures), + [&actions, keepAlive = item](long long, std::shared_ptr const& failure) -> Nui::ElementRenderer + { + return failureRow(actions, *failure); + } + ) + ); + // clang-format on } } From 7c02093b31a5ccb3b25a22cfe691a9761ee3379b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 070/114] Implemented the Options tab with the copy locks of the watch detail --- .../file_tracking/options_tab.cpp | 188 +++++++++++++++++- 1 file changed, 185 insertions(+), 3 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/options_tab.cpp b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp index c30a5da0..e5e47d45 100644 --- a/frontend/source/frontend/session_components/file_tracking/options_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp @@ -1,15 +1,197 @@ #include +#include +#include +#include +#include + +#include +#include +#include +#include #include +#include +#include +#include + namespace FileTracking { + namespace + { + /** + * @brief What one switch of the Options tab shows and changes. + */ + struct OptionSwitch + { + SharedData::WatchOption option; + std::string label; + std::string description; + std::shared_ptr> enabled; + /** @brief Off and disabled on a downloaded copy, which never moves or deletes the server's file. */ + bool neverForCopy; + }; + + std::array optionSwitchesOf(WatchItem const& item) + { + return { + OptionSwitch{ + .option = SharedData::WatchOption::AutoReupload, + .label = language->get("fileTracking", "columnAutoReupload"), + .description = language->get("fileTracking", "optionAutoReuploadDescription"), + .enabled = item.autoReupload, + .neverForCopy = false, + }, + OptionSwitch{ + .option = SharedData::WatchOption::MoveRemoteOnLocalMove, + .label = language->get("fileTracking", "columnMoveRemote"), + .description = language->get("fileTracking", "optionMoveRemoteDescription"), + .enabled = item.moveRemote, + .neverForCopy = true, + }, + OptionSwitch{ + .option = SharedData::WatchOption::DeleteRemoteOnLocalDelete, + .label = language->get("fileTracking", "columnDeleteRemote"), + .description = language->get("fileTracking", "optionDeleteRemoteDescription"), + .enabled = item.deleteRemote, + .neverForCopy = true, + }, + OptionSwitch{ + .option = SharedData::WatchOption::CheckRemoteChanges, + .label = language->get("fileTracking", "columnCheckRemote"), + .description = language->get("fileTracking", "optionCheckRemoteDescription"), + .enabled = item.checkRemote, + .neverForCopy = false, + }, + }; + } + + Nui::ElementRenderer lockedOptionRow(OptionSwitch const& option) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Attributes::title; + using Nui::Elements::div; + using Nui::Elements::span; + + // clang-format off + return div{ + class_ = "file-tracking-option", + "data-ft-option"_attr = Utility::enumToString(option.option), + "data-locked"_attr = true, + title = language->get("fileTracking", "downloadedCopyOptionHint"), + }( + ScriptNuiComponents::switch_({ + .isChecked = false, + .attributes = { + role = "switch", + "aria-checked"_attr = "false", + "aria-label"_attr = option.label, + disabled = true, + }, + }), + span{class_ = "file-tracking-option-label"}(option.label), + span{class_ = "file-tracking-option-description"}( + language->get("fileTracking", "optionNeverForCopy") + ) + ); + // clang-format on + } + + Nui::ElementRenderer + optionRow(TrackingActions const& actions, Ids::WatchId const& watchId, OptionSwitch const& option) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + using Nui::Elements::span; + + // clang-format off + return div{ + class_ = "file-tracking-option", + "data-ft-option"_attr = Utility::enumToString(option.option), + }( + ScriptNuiComponents::switch_({ + .isChecked = option.enabled, + .attributes = { + role = "switch", + "aria-checked"_attr = Nui::observe(option.enabled).generate( + [](bool enabled) + { + return std::string{enabled ? "true" : "false"}; + } + ), + "aria-label"_attr = option.label, + }, + .onChange = [&actions, watchId, which = option.option](bool enabled, Nui::WebApi::MouseEvent const&) + { + if (actions.setOption) + actions.setOption(watchId, which, enabled); + }, + }), + span{class_ = "file-tracking-option-label"}(option.label), + span{class_ = "file-tracking-option-description"}(option.description) + ); + // clang-format on + } + + Nui::ElementRenderer optionsBody(TrackingActions const& actions, WatchItem const& item, bool downloadedCopy) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto switches = optionSwitchesOf(item); + const auto rowOf = [&actions, &item, downloadedCopy](OptionSwitch const& option) + { + if (downloadedCopy && option.neverForCopy) + return lockedOptionRow(option); + return optionRow(actions, item.watchId, option); + }; + const auto copyHint = [downloadedCopy]() -> Nui::ElementRenderer + { + if (!downloadedCopy) + return Nui::nil(); + // clang-format off + return div{class_ = "file-tracking-options-hint"}( + language->get("fileTracking", "downloadedCopyOptionHint") + ); + // clang-format on + }; + + // clang-format off + return div{class_ = "file-tracking-options-body"}( + div{class_ = "file-tracking-options"}( + rowOf(switches[0]), + rowOf(switches[1]), + rowOf(switches[2]), + rowOf(switches[3]) + ), + copyHint() + ); + // clang-format on + } + } + Nui::ElementRenderer optionsTab( [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item + TrackingActions const& actions, + std::shared_ptr const& item ) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + // clang-format off + return div{class_ = "file-tracking-options-tab"}( + Nui::observe(item->downloadedCopy).generate( + [&actions, item](bool downloadedCopy) -> Nui::ElementRenderer + { + return optionsBody(actions, *item, downloadedCopy); + } + ) + ); + // clang-format on } } From d64d1cd20735993274ae041295edd0757ff6f5be Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:37:52 +0200 Subject: [PATCH 071/114] Implemented the detail tab registry and the tab strip with mounted panels --- .../file_tracking/detail_tabs.cpp | 148 +++++++++++++++++- 1 file changed, 141 insertions(+), 7 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp b/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp index 318140ec..d275900f 100644 --- a/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp +++ b/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp @@ -1,20 +1,154 @@ #include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include #include +#include +#include +#include + namespace FileTracking { + namespace + { + ScriptNuiComponents::TabStripTab tabOf(DetailTabEntry const& entry, WatchItem const& item) + { + auto count = entry.count ? entry.count(item) : nullptr; + std::optional badge{}; + if (count) + badge = + ScriptNuiComponents::TabStripBadge{.count = std::move(count), .styleVariant = entry.badgeVariant}; + return { + .key = Utility::enumToString(entry.tab), + .title = entry.title, + .badge = std::move(badge), + }; + } + + /** + * @brief The panel of @p entry, mounted while its tab is not chosen so its list keeps its scroll position. + */ + Nui::ElementRenderer panelOf( + DetailTabEntry const& entry, + TrackingStore& store, + TrackingActions const& actions, + std::shared_ptr const& item + ) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto key = Utility::enumToString(entry.tab); + + // clang-format off + return div{ + class_ = "file-tracking-tab-panel", + role = "tabpanel", + "aria-label"_attr = entry.title, + "data-ft-tab-panel"_attr = key, + hidden = Nui::observe(store.detailTabKey()).generate( + [key](std::string const& chosen) + { + return chosen != key; + } + ), + }( + entry.render ? entry.render(store, actions, item) : Nui::nil() + ); + // clang-format on + } + } + std::vector detailTabRegistry() { - return {}; + return { + DetailTabEntry{ + .tab = DetailTab::Conflicts, + .title = language->get("fileTracking", "tabConflicts"), + .count = + [](WatchItem const& item) + { + return item.conflictCount; + }, + .badgeVariant = ScriptNuiComponents::StyleVariant::Warning, + .render = &conflictsTab, + }, + DetailTabEntry{ + .tab = DetailTab::Failed, + .title = language->get("fileTracking", "tabFailed"), + .count = + [](WatchItem const& item) + { + return item.failureCount; + }, + .badgeVariant = ScriptNuiComponents::StyleVariant::Danger, + .render = &failuresTab, + }, + DetailTabEntry{ + .tab = DetailTab::Options, + .title = language->get("fileTracking", "tabOptions"), + .render = &optionsTab, + }, + }; } - Nui::ElementRenderer detailTabs( - [[maybe_unused]] TrackingStore& store, - [[maybe_unused]] TrackingActions const& actions, - [[maybe_unused]] std::shared_ptr const& item - ) + Nui::ElementRenderer + detailTabs(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) { - return Nui::nil(); + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto registry = detailTabRegistry(); + + std::vector tabs{}; + std::vector panels{}; + std::vector order{}; + tabs.reserve(registry.size()); + panels.reserve(registry.size()); + order.reserve(registry.size()); + for (auto const& entry : registry) + { + tabs.push_back(tabOf(entry, *item)); + panels.push_back(panelOf(entry, store, actions, item)); + order.push_back(entry.tab); + } + + // clang-format off + return div{class_ = "file-tracking-detail-tabs"}( + ScriptNuiComponents::tabStrip({ + .tabs = std::move(tabs), + .selected = store.detailTabKey(), + .onSelect = [&actions, order = std::move(order)](std::string const& key) + { + const auto chosen = std::ranges::find_if(order, [&key](DetailTab tab) + { + return Utility::enumToString(tab) == key; + }); + if (chosen != order.end() && actions.chooseTab) + actions.chooseTab(*chosen); + }, + .label = language->get("fileTracking", "detailTabsLabel"), + }), + div{class_ = "file-tracking-tab-panels"}( + Nui::range(std::move(panels)), + [](long long, Nui::ElementRenderer const& panel) -> Nui::ElementRenderer + { + return panel; + } + ) + ); + // clang-format on } } From 7a667071e5de9992ddef4d101e8c950e6780b471 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:39:14 +0200 Subject: [PATCH 072/114] Styled the detail tabs, the item list boxes and the options grid --- static/styles/file_tracking_tabs.css | 223 +++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css index e69de29b..db36b7aa 100644 --- a/static/styles/file_tracking_tabs.css +++ b/static/styles/file_tracking_tabs.css @@ -0,0 +1,223 @@ +.file-tracking-detail-tabs { + --file-tracking-tabs-warning: var(--script-nui-components-toast-warning, #e0a02e); + --file-tracking-tabs-danger: var(--script-nui-components-toast-error, #d9534f); + --script-nui-components-badge-warning-background: color-mix(in srgb, var(--file-tracking-tabs-warning) 30%, var(--brightness-mixin) 70%); + --script-nui-components-badge-warning-color: color-mix(in srgb, var(--file-tracking-tabs-warning) 60%, var(--color) 40%); + --script-nui-components-badge-danger-background: color-mix(in srgb, var(--file-tracking-tabs-danger) 30%, var(--brightness-mixin) 70%); + --script-nui-components-badge-danger-color: color-mix(in srgb, var(--file-tracking-tabs-danger) 60%, var(--color) 40%); + + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-width: 0; + min-height: 0; + + & > .script-nui-tab-strip { + flex-shrink: 0; + } +} + +.file-tracking-tab-panels { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.file-tracking-tab-panel { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + + &[hidden] { + display: none; + } +} + +.file-tracking-item-list-box { + --file-tracking-item-list-box-accent: var(--border-color-fields); + + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-width: 0; + min-height: 0; + font-size: 0.8rem; + + &[data-class="conflicts"] { + --file-tracking-item-list-box-accent: var(--script-nui-components-toast-warning, #e0a02e); + } + + &[data-class="failures"] { + --file-tracking-item-list-box-accent: var(--script-nui-components-toast-error, #d9534f); + } + + &[data-empty] > .file-tracking-item-list-box-toolbar, + &[data-empty] > .file-tracking-item-list-box-items, + &:not([data-empty]) > .file-tracking-item-list-box-empty { + display: none; + } + + & .script-nui-button { + height: 24px; + padding: 0 8px; + font-size: 0.75rem; + white-space: nowrap; + + & > svg { + width: 14px; + height: 14px; + } + } +} + +.file-tracking-item-list-box-toolbar { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} + +.file-tracking-item-list-box-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.file-tracking-item-list-box-hint { + margin-left: auto; + color: var(--color-secondary); + font-size: 0.75rem; + + &:empty { + display: none; + } +} + +.file-tracking-item-list-box-items { + flex: 0 1 auto; + min-height: 0; + overflow: auto; + border: 1px solid color-mix(in srgb, var(--file-tracking-item-list-box-accent) 45%, var(--brightness-mixin) 55%); + border-radius: 4px; + background: color-mix(in srgb, var(--file-tracking-item-list-box-accent) 7%, var(--brightness-mixin) 93%); +} + +.file-tracking-item-list-box-empty { + padding-top: 6px; + color: var(--color-secondary); +} + +.file-tracking-item-list { + display: flex; + flex-direction: column; +} + +.file-tracking-item-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + padding: 6px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--file-tracking-item-list-box-accent) 22%, transparent); + + &:last-child { + border-bottom: none; + } +} + +.file-tracking-item-row-text { + flex: 1 1 12rem; + min-width: 0; +} + +.file-tracking-item-row-file, +.file-tracking-item-row-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-tracking-item-row-detail { + color: var(--color-secondary); + font-size: 0.75rem; +} + +.file-tracking-item-row-actions { + display: flex; + flex: none; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.file-tracking-item-row-time { + min-width: 40px; + color: var(--color-secondary); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.file-tracking-failure-row .script-nui-button[data-variant="transparent"] { + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + color: var(--color-secondary); + + &:not(:disabled):hover { + border-color: var(--script-nui-components-button-border-color-regular, #5f5e5a); + background-color: var(--script-nui-components-button-color-regular, #202020); + color: color-mix(in srgb, var(--file-tracking-item-list-box-accent) 60%, var(--color) 40%); + } +} + +.file-tracking-options-tab { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding-top: 4px; +} + +.file-tracking-options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(max(15rem, calc(50% - 9px)), 1fr)); + gap: 12px 18px; +} + +.file-tracking-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 2px 10px; + font-size: 0.8rem; + + & > .script-nui-switch { + grid-row: 1; + grid-column: 1; + } + + &[data-locked] { + color: var(--color-secondary); + } +} + +.file-tracking-option-description { + grid-column: 2; + color: var(--color-secondary); + font-size: 0.72rem; +} + +.file-tracking-options-hint { + margin-top: 8px; + color: var(--color-secondary); + font-size: 0.72rem; +} From be40af8ef10fe08112e8408405e8749e296bb5f7 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:40:49 +0200 Subject: [PATCH 073/114] Kept the options rows mounted and followed the copy flag with attributes --- .../file_tracking/options_tab.cpp | 125 +++++++----------- static/styles/file_tracking_tabs.css | 4 + 2 files changed, 50 insertions(+), 79 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/options_tab.cpp b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp index e5e47d45..1808077c 100644 --- a/frontend/source/frontend/session_components/file_tracking/options_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp @@ -9,11 +9,11 @@ #include #include #include -#include #include #include #include +#include namespace FileTracking { @@ -28,7 +28,7 @@ namespace FileTracking std::string label; std::string description; std::shared_ptr> enabled; - /** @brief Off and disabled on a downloaded copy, which never moves or deletes the server's file. */ + /** @brief Disabled on a downloaded copy, the tracker keeps it off there. */ bool neverForCopy; }; @@ -66,7 +66,8 @@ namespace FileTracking }; } - Nui::ElementRenderer lockedOptionRow(OptionSwitch const& option) + Nui::ElementRenderer + optionRow(TrackingActions const& actions, std::shared_ptr const& item, OptionSwitch const& option) { using namespace Nui::Elements; using namespace Nui::Attributes; @@ -74,42 +75,26 @@ namespace FileTracking using Nui::Elements::div; using Nui::Elements::span; - // clang-format off - return div{ - class_ = "file-tracking-option", - "data-ft-option"_attr = Utility::enumToString(option.option), - "data-locked"_attr = true, - title = language->get("fileTracking", "downloadedCopyOptionHint"), - }( - ScriptNuiComponents::switch_({ - .isChecked = false, - .attributes = { - role = "switch", - "aria-checked"_attr = "false", - "aria-label"_attr = option.label, - disabled = true, - }, - }), - span{class_ = "file-tracking-option-label"}(option.label), - span{class_ = "file-tracking-option-description"}( - language->get("fileTracking", "optionNeverForCopy") - ) - ); - // clang-format on - } - - Nui::ElementRenderer - optionRow(TrackingActions const& actions, Ids::WatchId const& watchId, OptionSwitch const& option) - { - using namespace Nui::Elements; - using namespace Nui::Attributes; - using Nui::Elements::div; - using Nui::Elements::span; + const auto lockedNow = [item, neverForCopy = option.neverForCopy]() + { + return neverForCopy && item->downloadedCopy.value(); + }; + const auto textWhileLocked = [lockedNow](std::string unlocked, std::string locked) + { + return [lockedNow, unlocked = std::move(unlocked), locked = std::move(locked)]() + { + return lockedNow() ? locked : unlocked; + }; + }; // clang-format off return div{ class_ = "file-tracking-option", "data-ft-option"_attr = Utility::enumToString(option.option), + "data-locked"_attr = Nui::observe(item->downloadedCopy).generate(lockedNow), + title = Nui::observe(item->downloadedCopy).generate( + textWhileLocked("", language->get("fileTracking", "downloadedCopyOptionHint")) + ), }( ScriptNuiComponents::switch_({ .isChecked = option.enabled, @@ -122,52 +107,23 @@ namespace FileTracking } ), "aria-label"_attr = option.label, + disabled = Nui::observe(item->downloadedCopy).generate(lockedNow), }, - .onChange = [&actions, watchId, which = option.option](bool enabled, Nui::WebApi::MouseEvent const&) + .onChange = [&actions, watchId = item->watchId, which = option.option]( + bool enabled, + Nui::WebApi::MouseEvent const& + ) { if (actions.setOption) actions.setOption(watchId, which, enabled); }, }), span{class_ = "file-tracking-option-label"}(option.label), - span{class_ = "file-tracking-option-description"}(option.description) - ); - // clang-format on - } - - Nui::ElementRenderer optionsBody(TrackingActions const& actions, WatchItem const& item, bool downloadedCopy) - { - using namespace Nui::Elements; - using namespace Nui::Attributes; - using Nui::Elements::div; - - const auto switches = optionSwitchesOf(item); - const auto rowOf = [&actions, &item, downloadedCopy](OptionSwitch const& option) - { - if (downloadedCopy && option.neverForCopy) - return lockedOptionRow(option); - return optionRow(actions, item.watchId, option); - }; - const auto copyHint = [downloadedCopy]() -> Nui::ElementRenderer - { - if (!downloadedCopy) - return Nui::nil(); - // clang-format off - return div{class_ = "file-tracking-options-hint"}( - language->get("fileTracking", "downloadedCopyOptionHint") - ); - // clang-format on - }; - - // clang-format off - return div{class_ = "file-tracking-options-body"}( - div{class_ = "file-tracking-options"}( - rowOf(switches[0]), - rowOf(switches[1]), - rowOf(switches[2]), - rowOf(switches[3]) - ), - copyHint() + span{class_ = "file-tracking-option-description"}( + text{Nui::observe(item->downloadedCopy).generate( + textWhileLocked(option.description, language->get("fileTracking", "optionNeverForCopy")) + )}() + ) ); // clang-format on } @@ -183,14 +139,25 @@ namespace FileTracking using namespace Nui::Attributes; using Nui::Elements::div; + const auto switches = optionSwitchesOf(*item); + // clang-format off return div{class_ = "file-tracking-options-tab"}( - Nui::observe(item->downloadedCopy).generate( - [&actions, item](bool downloadedCopy) -> Nui::ElementRenderer - { - return optionsBody(actions, *item, downloadedCopy); - } - ) + div{class_ = "file-tracking-options"}( + optionRow(actions, item, switches[0]), + optionRow(actions, item, switches[1]), + optionRow(actions, item, switches[2]), + optionRow(actions, item, switches[3]) + ), + div{ + class_ = "file-tracking-options-hint", + hidden = Nui::observe(item->downloadedCopy).generate( + [item]() + { + return !item->downloadedCopy.value(); + } + ), + }(language->get("fileTracking", "downloadedCopyOptionHint")) ); // clang-format on } diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css index db36b7aa..b4f6448c 100644 --- a/static/styles/file_tracking_tabs.css +++ b/static/styles/file_tracking_tabs.css @@ -220,4 +220,8 @@ margin-top: 8px; color: var(--color-secondary); font-size: 0.72rem; + + &[hidden] { + display: none; + } } From b1d2d2665c07fd8dcc57383ee9861da977c51b53 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:42:32 +0200 Subject: [PATCH 074/114] Wrapped the row buttons earlier so failure reasons stay readable when narrow --- static/styles/file_tracking_tabs.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css index b4f6448c..38baed4b 100644 --- a/static/styles/file_tracking_tabs.css +++ b/static/styles/file_tracking_tabs.css @@ -134,7 +134,7 @@ } .file-tracking-item-row-text { - flex: 1 1 12rem; + flex: 1 1 15rem; min-width: 0; } From b99336ec40e1052480ad1dbc7269f0e1e540f362 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 15:46:04 +0200 Subject: [PATCH 075/114] Let the detail tab area shrink so its list boxes scroll on their own --- static/styles/file_tracking_panel.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/styles/file_tracking_panel.css b/static/styles/file_tracking_panel.css index a5e5dd75..0c06f339 100644 --- a/static/styles/file_tracking_panel.css +++ b/static/styles/file_tracking_panel.css @@ -50,7 +50,7 @@ flex-direction: column; &>:last-child { - flex: 1 0 auto; + flex: 1 1 auto; } } From d91ff0440b5a538d1a2715ca31c3553c5564c840 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 16:02:29 +0200 Subject: [PATCH 076/114] Showed the upload progress per watch, throttled to five snapshots a second - Each entry has a WatchProgressMeter fed with its queued uploads and file sizes, the remote's progress, the ends, the pending changes and the conflicts waiting; a burst ends right after the handler that left nothing on its way - One 200 ms timer shows the watches whose progress changed, never twice within 200 ms; an idle watch is shown at once or when its interval passed; a new entry is shown with its progress; stop, stopAll and connectionLost show none - The session-wide TrackerProgress is no longer sent, the fake view fails on showProgress - The progress tests read showWatchProgress: a burst's done count moves to lastSync when it ends; the idle-at-once test waits for the interval first; the lost connection tests expect no progress after the entries went --- .../source/backend/file_tracking/tracker.cpp | 240 ++++++--- backend/test/backend/test_tracker.hpp | 510 ++++++++++++++++-- 2 files changed, 626 insertions(+), 124 deletions(-) diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 00cec95d..294c8d34 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -114,6 +115,10 @@ namespace FileTracking std::size_t remoteChangesOnTheirWay{0}; /** @brief What the view shows, so it hears only of changes. */ bool shownUploading{false}; + /** @brief How far its changes are, by the local paths its uploads started with. */ + WatchProgressMeter progress; + /** @brief When the view last heard of the progress, it hears of it once per progress interval at most. */ + std::chrono::steady_clock::time_point progressShownAt{}; DownloadedCopy const* downloadedCopy() const { @@ -225,6 +230,32 @@ namespace FileTracking return ServerState{.mtime = secondsSinceEpochOf(sent.modified), .size = sent.size}; } + /** + * @brief The size of the regular file at @p path, nothing for anything else. + */ + std::optional fileSizeOf(Paths::LocalPath const& path) + { + return sourceStateOf(path.native()) + .transform( + [](SourceState const& state) + { + return state.size; + } + ); + } + + /** + * @brief How the upload that ended as @p ended counts for the progress: one that found the server's copy + * changed did not complete. + */ + WatchProgressMeter::UploadResult uploadResultOf(UploadEnd const& ended) + { + const bool completed = + ended.reason == SharedData::OperationCompletionReason::Completed && !ended.changedOnServer; + return completed ? WatchProgressMeter::UploadResult::Completed + : WatchProgressMeter::UploadResult::NotCompleted; + } + /** * @brief What the server has at the destination of an upload instead of what the tracker left there. */ @@ -392,7 +423,7 @@ namespace FileTracking struct Tracker::Implementation : public std::enable_shared_from_this { - /** @brief How often the view hears of the progress at most, a burst of changes costs a few events. */ + /** @brief How often the view hears of the progress of a watch at most. */ constexpr static std::chrono::milliseconds progressInterval{200}; /** @@ -456,12 +487,10 @@ namespace FileTracking ConflictList conflicts{}; /** @brief The failed uploads waiting for Retry or Dismiss. */ FailureList failures{}; - /** @brief Changes that ended since nothing was on its way any more, see showProgress(). */ - std::uint64_t changesDone{0}; - SharedData::TrackerProgress shownProgress{}; + /** @brief Shows the progress that changed, see showDueProgress(). */ boost::asio::steady_timer progressTimer; bool progressScheduled{false}; - /** @brief The view hears of the progress once the current handler ended, see progressChanged(). */ + /** @brief The idle entries are shown once the current handler ended, see showIdleProgress(). */ bool idleProgressPosted{false}; ServerOperationOrder serverOrder{}; /** @brief Start the operations that wait in serverOrder, by ticket. */ @@ -572,91 +601,135 @@ namespace FileTracking view->showEntry(shownEntryOf(entry)); } - SharedData::TrackerProgress currentProgress() const + void showProgress(Entry& entry, std::chrono::steady_clock::time_point now) { - std::uint64_t pending = 0; - for (auto const& entry : std::views::values(entries)) - pending += entry.pendingUploads.changeCount() + entry.remoteChangesOnTheirWay; - return { - .pending = pending, - .done = changesDone, - .waitingForAnswer = conflicts.size(), - }; + entry.progress.takeDirty(); + entry.progressShownAt = now; + view->showWatchProgress(entry.progress.snapshot()); } /** - * @brief Whether no change is on its way any more, nor waits for an answer. + * @brief Shows the progress of @p entry when it changed, at most once per progress interval. An entry with + * nothing on its way ends its burst first. + * @return When it may be shown, nothing when it was shown or did not change. */ - bool nothingOnItsWay() const + std::optional + showChangedProgress(Entry& entry, std::chrono::steady_clock::time_point now) { - return conflicts.empty() && - std::ranges::all_of( - std::views::values(entries), - [](Entry const& entry) - { - return entry.pendingUploads.empty() && entry.remoteChangesOnTheirWay == 0; - } - ); + if (entry.progress.isIdle()) + entry.progress.finishBurst(secondsSinceEpochNow()); + if (!entry.progress.isDirty()) + return std::nullopt; + if (const auto due = entry.progressShownAt + progressInterval; now < due) + return due; + showProgress(entry, now); + return std::nullopt; } /** - * @brief The view hears of the progress once the progress interval passed, or right after the change that - * left nothing on its way, together with the rows that stopped uploading. + * @brief The counts of @p entry may have changed or its uploads went on: the view hears of it once the + * progress interval passed, or right after the change that left nothing on its way, see + * showIdleProgress(). */ - void progressChanged() + void progressChanged(Entry& entry) + { + entry.progress.setChangesPending(entry.pendingUploads.changeCount() + entry.remoteChangesOnTheirWay); + entry.progress.setWaitingForAnswer(conflicts.countOf(entry.watchId)); + if (!entry.progress.isDirty()) + return; + if (entry.progress.isIdle()) + return postIdleProgress(); + scheduleProgress(std::chrono::steady_clock::now() + progressInterval); + } + + void progressChanged(Ids::WatchId const& watchId) + { + if (auto* entry = find(watchId)) + progressChanged(*entry); + } + + void postIdleProgress() { - if (nothingOnItsWay() && !idleProgressPosted) + if (std::exchange(idleProgressPosted, true)) + return; + // Posted, so the changes the end of an upload starts are on their way by then. + boost::asio::post( + strand, + [weak = weak_from_this()]() + { + if (const auto self = weak.lock()) + self->showIdleProgress(); + } + ); + } + + /** + * @brief The entries with nothing on their way end their burst. The view hears of them at once, or once the + * progress interval since it last did passed. + */ + void showIdleProgress() + { + idleProgressPosted = false; + const auto now = std::chrono::steady_clock::now(); + for (auto& entry : std::views::values(entries)) { - idleProgressPosted = true; - // Posted, so the changes the end of an upload starts are on their way by then. - boost::asio::post( - strand, - [weak = weak_from_this()]() - { - const auto self = weak.lock(); - if (!self) - return; - self->idleProgressPosted = false; - if (!self->nothingOnItsWay()) - return; - self->progressTimer.cancel(); - self->showProgress(); - } - ); + if (!entry.progress.isIdle()) + continue; + if (const auto due = showChangedProgress(entry, now)) + scheduleProgress(*due); } + } + + void scheduleProgress(std::chrono::steady_clock::time_point at) + { if (std::exchange(progressScheduled, true)) return; - progressTimer.expires_after(progressInterval); + progressTimer.expires_at(at); progressTimer.async_wait( [weak = weak_from_this()](boost::system::error_code const& error) { if (error) return; if (const auto self = weak.lock()) - self->showProgress(); + self->showDueProgress(); } ); } - void showProgress() + /** + * @brief Shows the progress that changed, the timer runs again for that of entries shown too recently. + */ + void showDueProgress() { progressScheduled = false; - const auto progress = currentProgress(); - if (progress != shownProgress) - view->showProgress(progress); - shownProgress = progress; - // The next burst counts from zero. - if (progress.pending == 0) - changesDone = 0; + const auto now = std::chrono::steady_clock::now(); + std::optional next; + for (auto& entry : std::views::values(entries)) + if (const auto due = showChangedProgress(entry, now); due && (!next || *due < *next)) + next = due; + if (next) + scheduleProgress(*next); } /** * @param count The changes it counted as, see PendingUploads::changeCount(). */ - void changeEnded(std::size_t count = 1) + void changeEnded(Entry& entry, std::size_t count = 1) { - changesDone += count; - progressChanged(); + entry.progress.changesEnded(count); + progressChanged(entry); + } + + /** + * @brief The upload of @p path in the watch @p watchId, the path it started with, is as far as @p progress. + */ + void onUploadProgress(Ids::WatchId const& watchId, Paths::LocalPath const& path, UploadProgress const& progress) + { + auto* entry = find(watchId); + if (!entry) + return; + entry->progress.uploadProgressed(path, progress); + progressChanged(*entry); } /** @@ -665,15 +738,18 @@ namespace FileTracking TrackerRemote::OnEnded remoteChangeOf(Entry& entry) { ++entry.remoteChangesOnTheirWay; - progressChanged(); + progressChanged(entry); return [weak = weak_from_this(), watchId = entry.watchId](SharedData::OperationCompletionReason) { const auto self = weak.lock(); if (!self) return; - if (auto* ended = self->find(watchId); ended && ended->remoteChangesOnTheirWay > 0) + auto* ended = self->find(watchId); + if (!ended) + return; + if (ended->remoteChangesOnTheirWay > 0) --ended->remoteChangesOnTheirWay; - self->changeEnded(); + self->changeEnded(*ended); }; } @@ -727,13 +803,14 @@ namespace FileTracking } /** - * @brief Shows @p entry and mirrors its changes from now on. + * @brief Shows @p entry with its progress and mirrors its changes from now on. */ Entry& track(Entry entry) { const auto watchId = entry.watchId; auto& tracked = entries.insert_or_assign(watchId, std::move(entry)).first->second; show(tracked); + showProgress(tracked, std::chrono::steady_clock::now()); return tracked; } @@ -845,6 +922,7 @@ namespace FileTracking .remotePath = remotePath, .localPath = copy.localPath, .options = downloadedCopyOptions(defaultOptions()), + .progress = WatchProgressMeter{watchId}, }); // The downloaded files carry the server's modification time, so they tell what the server had. rememberStates(entry, std::nullopt); @@ -915,6 +993,7 @@ namespace FileTracking .remotePath = destination, .localPath = *watchedItem, .options = defaultOptions(), + .progress = WatchProgressMeter{watchId}, }); // Goes like a change, so what changes while it uploads goes once more afterwards. queueUpload(Upload{ @@ -969,7 +1048,6 @@ namespace FileTracking failures.removeWatch(watchId); hideProblems(shown); view->removeEntry(watchId); - progressChanged(); for (auto const& listed : dropped) leaveOrder(listed.context); const auto* copy = node.mapped().downloadedCopy(); @@ -1049,9 +1127,6 @@ namespace FileTracking leaveOrder(ticket); progressTimer.cancel(); progressScheduled = false; - changesDone = 0; - // A frontend that comes next starts with nothing on its way, the idle progress has nothing to tell it. - shownProgress = {}; } void connectionLost() @@ -1064,16 +1139,12 @@ namespace FileTracking collectShownProblems(entry.watchId, shown); } Log::info("FileTracking: the connection was lost, ending {} watch(es)", ended.entries.size()); - // stopAll() forgets the progress shown, this view still shows it. - const bool progressShown = shownProgress != SharedData::TrackerProgress{}; stopAll(); hideProblems(shown); for (auto const& endedEntry : ended.entries) view->removeEntry(endedEntry.watchId); if (!ended.entries.empty()) view->showEnded(ended); - if (progressShown) - view->showProgress(shownProgress); view->showOrphanedCount(instances->countOrphaned()); } @@ -1255,7 +1326,7 @@ namespace FileTracking if (entry.pendingUploads.covers(path)) { entry.pendingUploads.start({.path = path}); - return progressChanged(); + return progressChanged(entry); } Log::debug("FileTracking: '{}' is not on the server, nothing to delete there.", path.toUtf8()); return; @@ -1805,7 +1876,7 @@ namespace FileTracking entry->serverContent.hasDirectory(upload.change.path)) return uploadWhatTheServerLacks(*entry, upload); const bool starts = entry->pendingUploads.start(upload.change); - progressChanged(); + progressChanged(*entry); if (!starts) return discardPartial(upload.continuesPartialOf); showUploading(*entry); @@ -1968,6 +2039,7 @@ namespace FileTracking */ void askServerChanged(Upload upload, ServerChange const& change) { + const auto watchId = upload.watchId; if (conflictStands(upload)) { auto added = conflicts.add(conflictOf(upload, change), std::move(upload)); @@ -1985,7 +2057,7 @@ namespace FileTracking ); view->showConflict(added.conflict); } - progressChanged(); + progressChanged(watchId); } /** @@ -2027,9 +2099,10 @@ namespace FileTracking Log::warn("FileTracking: there is no conflict {} to answer", conflictId.value()); return; } + const auto watchId = listed->conflict.watchId; resolveConflict(std::move(listed->context), resolution); view->removeConflict(conflictId); - progressChanged(); + progressChanged(watchId); } void answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) @@ -2051,7 +2124,7 @@ namespace FileTracking resolveConflict(std::move(conflict.context), resolution); view->removeConflict(conflict.conflict.conflictId); } - progressChanged(); + progressChanged(watchId); } void resolveConflict(Upload upload, SharedData::ConflictResolution resolution) @@ -2261,6 +2334,8 @@ namespace FileTracking if (upload.change.isDirectory) entry->pendingUploads.carries(upload.change.path, entryCountOf(upload.change.path.native())); entry->pendingUploads.sent(upload.change.path, upload.destination, upload.change.isDirectory); + entry->progress.uploadQueued(upload.change.path, fileSizeOf(upload.change.path)); + progressChanged(*entry); const auto queued = remote->upload( UploadRequest{ .source = upload.change.path, @@ -2276,19 +2351,28 @@ namespace FileTracking if (const auto self = weak.lock()) self->onUploadEnded(upload, expected, ended); }, - {} + [weak = weak_from_this(), + watchId = upload.watchId, + path = upload.change.path](UploadProgress const& progress) + { + if (const auto self = weak.lock()) + self->onUploadProgress(watchId, path, progress); + } ); if (queued) return; // A temporary file an editor already renamed or removed ends up here. Log::warn("FileTracking: did not upload '{}': {}", upload.change.path.toUtf8(), queued.error()); - if (const auto* stillWatched = find(upload.watchId)) + if (auto* stillWatched = find(upload.watchId)) + { + stillWatched->progress.uploadEnded(upload.change.path, WatchProgressMeter::UploadResult::NotCompleted); recordFailure( *stillWatched, upload.change.path, upload.destination, {.reason = SharedData::UploadFailureReason::NotQueued, .detail = queued.error()} ); + } finishUpload(upload, UploadOutcome::NotUploaded); } @@ -2301,6 +2385,8 @@ namespace FileTracking onUploadEnded(Upload const& upload, std::optional const& expected, UploadEnd const& ended) { auto* entry = find(upload.watchId); + if (entry) + entry->progress.uploadEnded(upload.change.path, uploadResultOf(ended)); // The server's copy changed meanwhile and stayed, the upload asks about it now unless the user stopped // checking since. if (entry && ended.changedOnServer) @@ -2408,7 +2494,7 @@ namespace FileTracking { auto finished = entry.pendingUploads.finish(upload.change.path); showUploading(entry); - changeEnded(carried.value_or(finished.counted)); + changeEnded(entry, carried.value_or(finished.counted)); // A directory above that moved took the server copy along. const auto serverPath = finished.serverCopyAt.value_or(upload.change.path); const auto serverDestination = finished.serverCopyAt diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index be4d684d..81118470 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -208,9 +209,9 @@ namespace Test orphanedCounts.push_back(count); } - void showProgress(SharedData::TrackerProgress const& shown) override + void showProgress(SharedData::TrackerProgress const&) override { - progress.push_back(shown); + ADD_FAILURE() << "the tracker shows the progress per watch"; } void showConflict(SharedData::WatchConflict const& conflict) override @@ -239,6 +240,7 @@ namespace Test void showWatchProgress(SharedData::WatchProgress const& shown) override { watchProgress.push_back(shown); + watchProgressShownAt.push_back(std::chrono::steady_clock::now()); } void showOrphanedCopies(SharedData::OrphanedCopies const& copies) override @@ -250,7 +252,6 @@ namespace Test std::vector removed{}; std::vector ended{}; std::vector orphanedCounts{}; - std::vector progress{}; /** @brief The conflicts shown and not removed, by id. */ std::map conflicts{}; /** @brief Every conflict shown, in order. */ @@ -260,9 +261,31 @@ namespace Test std::map failures{}; std::vector removedFailures{}; std::vector watchProgress{}; + /** @brief When each of watchProgress was shown. */ + std::vector watchProgressShownAt{}; std::vector orphanedCopies{}; }; + /** + * @brief The counts of a watch's progress a test compares: its changes, and those of its last sync. + */ + struct ProgressCounts + { + std::uint64_t pending{0u}; + std::uint64_t done{0u}; + std::uint64_t waitingForAnswer{0u}; + std::optional synced{}; + + bool operator==(ProgressCounts const&) const = default; + + friend void PrintTo(ProgressCounts const& counts, std::ostream* stream) + { + *stream << "{pending " << counts.pending << ", done " << counts.done << ", waitingForAnswer " + << counts.waitingForAnswer << ", synced " + << (counts.synced ? std::to_string(*counts.synced) : std::string{"none"}) << "}"; + } + }; + using Opened = std::expected; boost::asio::io_context ioc_; @@ -692,26 +715,121 @@ namespace Test ); } + Ids::WatchId watchIdNamed(std::string const& name) const + { + for (auto const& entry : std::views::values(view_->entries)) + if (entry.name == name) + return entry.watchId; + ADD_FAILURE() << "no entry is named " << name; + return {}; + } + + static ProgressCounts countsOf(SharedData::WatchProgress const& progress) + { + return { + .pending = progress.changesPending, + .done = progress.changesDone, + .waitingForAnswer = progress.waitingForAnswer, + .synced = progress.lastSync ? std::optional{progress.lastSync->changes} : std::nullopt, + }; + } + + /** + * @brief The progress the view showed of @p watchId, in order. + */ + std::vector progressOf(Ids::WatchId const& watchId) const + { + std::vector shown; + for (auto const& progress : view_->watchProgress) + if (progress.watchId == watchId) + shown.push_back(progress); + return shown; + } + /** - * @brief The progress the view showed last, nothing on its way before it showed any. + * @brief When the view showed the progress of @p watchId, in order. */ - SharedData::TrackerProgress lastProgress() const + std::vector progressTimesOf(Ids::WatchId const& watchId) const + { + std::vector times; + for (std::size_t index = 0; index < view_->watchProgress.size(); ++index) + if (view_->watchProgress[index].watchId == watchId) + times.push_back(view_->watchProgressShownAt[index]); + return times; + } + + std::optional lastProgressOf(Ids::WatchId const& watchId) const { - return view_->progress.empty() ? SharedData::TrackerProgress{} : view_->progress.back(); + auto shown = progressOf(watchId); + if (shown.empty()) + return std::nullopt; + return std::move(shown.back()); } /** - * @brief Runs the handlers until the view shows @p expected. + * @brief Runs the handlers until the progress the view showed last of @p watchId passes @p condition. */ - bool runUntilProgress(SharedData::TrackerProgress const& expected) + bool runUntilProgress( + Ids::WatchId const& watchId, + std::function const& condition + ) { return runUntil( - [this, &expected]() + [this, &watchId, &condition]() { - return lastProgress() == expected; + const auto last = lastProgressOf(watchId); + return last && condition(*last); } ); } + + /** + * @brief Runs the handlers until the progress the view showed last of @p watchId has @p expected counts. + */ + bool runUntilProgress(Ids::WatchId const& watchId, ProgressCounts const& expected) + { + const bool shown = runUntilProgress( + watchId, + [&expected](SharedData::WatchProgress const& progress) + { + return countsOf(progress) == expected; + } + ); + if (!shown) + EXPECT_EQ(lastProgressOf(watchId).transform(countsOf), std::optional{expected}); + return shown; + } + + /** + * @brief The progress of the upload of the single file @p path, @p sent of @p total bytes. + */ + static FileTracking::UploadProgress fileProgress( + std::filesystem::path const& path, + std::uint64_t sent, + std::uint64_t total, + bool transferred = false + ) + { + std::vector inFlight; + if (!transferred) + inFlight.push_back({.path = local(path), .bytes = sent, .totalBytes = total}); + return { + .bytesSent = sent, + .bytesTotal = total, + .inFlight = std::move(inFlight), + .filesDone = transferred ? 1u : 0u, + .fileCount = 1u, + .transferred = transferred, + }; + } + + static std::vector inFlightPathsOf(SharedData::WatchProgress const& progress) + { + std::vector paths; + for (auto const& file : progress.inFlight) + paths.push_back(file.path.native()); + return paths; + } }; TEST_F(TrackerTest, OpeningWithoutWatchingDownloadsIntoAnInstanceAndTracksNothing) @@ -998,60 +1116,277 @@ namespace Test { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; write(opened->localPath.native(), "changed locally"); - ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u})); completeUploads(); - ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 1u, .waitingForAnswer = 0u})); - const auto shown = view_->progress.size(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 1u})); + const auto shown = view_->watchProgress.size(); runFor(std::chrono::milliseconds{500}); - EXPECT_EQ(view_->progress.size(), shown); + EXPECT_EQ(view_->watchProgress.size(), shown); } TEST_F(TrackerTest, TheProgressShowsAChangeWaitingForAnAnswer) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; remote_->server["/srv/notes.txt"].mtime += 100u; write(opened->localPath.native(), "changed locally"); - ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u})); completeUploads(); - ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 1u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u, .waitingForAnswer = 1u})); + EXPECT_EQ(lastProgressOf(watchId)->changesWaiting, 1u); tracker_->answerConflict(onlyConflict().conflictId, SharedData::ConflictResolution::KeepServers); - ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 1u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 1u})); } TEST_F(TrackerTest, TheProgressCountsADeleteOnTheServerUntilItEnded) { ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; completeUploads(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 3u})); + + std::filesystem::remove(root_ / "local" / "project" / "src" / "main.cpp"); + ASSERT_TRUE(runUntil( - [this]() + [this, &watchId]() + { + return !remote_->of("remove").empty() && lastProgressOf(watchId)->changesPending > 0u; + } + )); + end(*remote_->of("remove").front(), SharedData::OperationCompletionReason::Completed); + completeUploads(); + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) { - return !view_->progress.empty() && lastProgress().pending == 0u; + return progress.changesPending == 0u; } )); + const auto last = lastProgressOf(watchId); + EXPECT_EQ(last->changesDone, 0u); + ASSERT_TRUE(last->lastSync.has_value()); + EXPECT_GE(last->lastSync->changes, 1u); + } - std::filesystem::remove(root_ / "local" / "project" / "src" / "main.cpp"); + TEST_F(TrackerTest, AFileUploadShowsItsBytesRisingToItsTotalAndEndsIdleWithItsLastSync) + { + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; + const auto copy = opened->localPath.native(); + // A new entry is shown with its progress. + ASSERT_EQ(progressOf(watchId).size(), 1u); + EXPECT_EQ(countsOf(progressOf(watchId).front()), ProgressCounts{}); + EXPECT_EQ(progressOf(watchId).front().bytesTotal, 0u); + + write(copy, std::string(1000u, 'x')); + + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u})); + EXPECT_EQ(lastProgressOf(watchId)->bytesDone, 0u); + EXPECT_EQ(lastProgressOf(watchId)->bytesTotal, 1000u); + EXPECT_EQ(lastProgressOf(watchId)->changesWaiting, 1u); + for (const std::uint64_t sent : {250u, 600u}) + { + lastUploadTo("/srv/notes.txt")->onUploadProgress(fileProgress(copy, sent, 1000u)); + ASSERT_TRUE(runUntilProgress( + watchId, + [sent](SharedData::WatchProgress const& progress) + { + return progress.bytesDone == sent; + } + )); + const auto shown = *lastProgressOf(watchId); + EXPECT_EQ(shown.bytesTotal, 1000u); + EXPECT_EQ(countsOf(shown), ProgressCounts{.pending = 1u}); + EXPECT_EQ(shown.changesWaiting, 0u); + ASSERT_EQ(inFlightPathsOf(shown), std::vector{copy}); + EXPECT_EQ(shown.inFlight.front().bytes, sent); + EXPECT_EQ(shown.inFlight.front().totalBytes, 1000u); + } + lastUploadTo("/srv/notes.txt")->onUploadProgress(fileProgress(copy, 1000u, 1000u, /*transferred=*/true)); + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.bytesDone == 1000u; + } + )); + EXPECT_EQ(countsOf(*lastProgressOf(watchId)), ProgressCounts{.done = 1u}); + EXPECT_TRUE(lastProgressOf(watchId)->inFlight.empty()); - ASSERT_TRUE(runUntil( - [this]() + completeUploads(); + + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 1u})); + const auto idle = *lastProgressOf(watchId); + EXPECT_EQ(idle.bytesDone, 0u); + EXPECT_EQ(idle.bytesTotal, 0u); + EXPECT_TRUE(idle.inFlight.empty()); + EXPECT_EQ(idle.lastSync->bytes, 1000u); + EXPECT_GT(idle.lastSync->endedAt, 0u); + } + + TEST_F(TrackerTest, TwoWatchesShowTheirProgressApart) + { + const auto notes = root_ / "local" / "notes.txt"; + ASSERT_TRUE(tracker_->uploadAndWatch(local(notes), remote("/srv/notes.txt")).has_value()); + ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + const auto notesId = watchIdNamed("notes.txt"); + const auto projectId = watchIdNamed("project"); + ASSERT_TRUE(runUntilProgress(notesId, {.pending = 1u})); + ASSERT_TRUE(runUntilProgress(projectId, {.pending = 3u})); + + lastUploadTo("/srv/notes.txt")->onUploadProgress(fileProgress(notes, 2u, 5u)); + + ASSERT_TRUE(runUntilProgress( + notesId, + [](SharedData::WatchProgress const& progress) { - return !remote_->of("remove").empty() && lastProgress().pending > 0u; + return progress.bytesDone == 2u; } )); - end(*remote_->of("remove").front(), SharedData::OperationCompletionReason::Completed); + EXPECT_EQ(inFlightPathsOf(*lastProgressOf(notesId)), std::vector{notes}); + EXPECT_EQ(lastProgressOf(projectId)->bytesDone, 0u); + EXPECT_TRUE(lastProgressOf(projectId)->inFlight.empty()); + completeWith(*lastUploadTo("/srv/notes.txt"), stateOf(notes)); + ASSERT_TRUE(runUntilProgress(notesId, {.synced = 1u})); + EXPECT_EQ(lastProgressOf(notesId)->lastSync->bytes, 5u); + EXPECT_EQ(countsOf(*lastProgressOf(projectId)), ProgressCounts{.pending = 3u}); + completeUploads(); + ASSERT_TRUE(runUntilProgress(projectId, {.synced = 3u})); + EXPECT_EQ(countsOf(*lastProgressOf(notesId)), ProgressCounts{.synced = 1u}); + for (auto const& shown : progressOf(projectId)) + EXPECT_TRUE(shown.inFlight.empty()); + } + + TEST_F(TrackerTest, ADirectoryUploadShowsItsFilesDoneOfAllAndThoseInFlightByLocalPath) + { + const auto project = root_ / "local" / "project"; + const auto mainFile = project / "src" / "main.cpp"; + const auto utilFile = project / "src" / "util.cpp"; + write(utilFile, "util"); + write(project / "readme.md", "readme"); + ASSERT_TRUE(tracker_->uploadAndWatch(local(project), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 5u})); + EXPECT_EQ(lastProgressOf(watchId)->changesWaiting, 5u); + + // The directory and src are done, both files on their way. + lastUploadTo("/srv/project") + ->onUploadProgress({ + .bytesSent = 8u, + .bytesTotal = 30u, + .inFlight = + {{.path = local(mainFile), .bytes = 6u, .totalBytes = 13u}, + {.path = local(utilFile), .bytes = 2u, .totalBytes = 4u}}, + .filesDone = 2u, + .fileCount = 5u, + .transferred = false, + }); + + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 3u, .done = 2u})); + const auto running = *lastProgressOf(watchId); + EXPECT_EQ(running.changesWaiting, 1u); + EXPECT_EQ(running.bytesDone, 8u); + EXPECT_EQ(running.bytesTotal, 30u); + EXPECT_EQ(inFlightPathsOf(running), (std::vector{mainFile, utilFile})); + EXPECT_EQ(running.inFlight.back().bytes, 2u); + EXPECT_EQ(running.inFlight.back().totalBytes, 4u); + lastUploadTo("/srv/project") + ->onUploadProgress({ + .bytesSent = 30u, + .bytesTotal = 30u, + .inFlight = {}, + .filesDone = 5u, + .fileCount = 5u, + .transferred = true, + }); + ASSERT_TRUE(runUntilProgress(watchId, {.done = 5u})); + EXPECT_EQ(lastProgressOf(watchId)->bytesDone, 30u); + EXPECT_TRUE(lastProgressOf(watchId)->inFlight.empty()); completeUploads(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 5u})); + EXPECT_EQ(lastProgressOf(watchId)->lastSync->bytes, 30u); + } + + TEST_F(TrackerTest, ManyProgressReportsWithinTheIntervalGiveFewSnapshotsAndTheIdleOneStillFollows) + { + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; + const auto copy = opened->localPath.native(); + write(copy, std::string(1000u, 'x')); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u})); + const auto before = progressOf(watchId).size(); + const auto started = std::chrono::steady_clock::now(); + + for (std::uint64_t sent = 20u; sent <= 1000u; sent += 20u) + { + lastUploadTo("/srv/notes.txt")->onUploadProgress(fileProgress(copy, sent, 1000u, sent == 1000u)); + ioc_.restart(); + ioc_.run_for(std::chrono::milliseconds{5}); + } + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.bytesDone == 1000u; + } + )); + // Within the interval of that snapshot, the idle one waits for it to pass. + completeUploads(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 1u})); + const auto elapsed = std::chrono::steady_clock::now() - started; + runFor(std::chrono::milliseconds{300}); + + const auto shown = progressOf(watchId); + const auto times = progressTimesOf(watchId); + EXPECT_LE(shown.size() - before, static_cast(elapsed / std::chrono::milliseconds{200}) + 2u); + EXPECT_EQ(countsOf(shown.back()), ProgressCounts{.synced = 1u}); + for (std::size_t index = 1u; index < times.size(); ++index) + EXPECT_GE(times[index] - times[index - 1u], std::chrono::milliseconds{195}) << "progress " << index; + } + + TEST_F(TrackerTest, AStoppedOrDiscardedWatchShowsNoProgressAfterwards) + { + const auto file = root_ / "local" / "project" / "big.bin"; + ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + const auto projectId = onlyEntry().watchId; + completeEverything(); + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + const auto copyId = watchIdNamed("notes.txt"); + write(file, "large content"); + write(opened->localPath.native(), "changed copy"); ASSERT_TRUE(runUntil( [this]() { - return lastProgress().pending == 0u; + return uploadsTo("/srv/project/big.bin") == 1u && uploadsTo("/srv/notes.txt") == 1u; } )); - EXPECT_GE(lastProgress().done, 1u); + const auto onFileProgress = lastUploadTo("/srv/project/big.bin")->onUploadProgress; + const auto onCopyProgress = lastUploadTo("/srv/notes.txt")->onUploadProgress; + onFileProgress(fileProgress(file, 3u, 13u)); + onCopyProgress(fileProgress(opened->localPath.native(), 4u, 12u)); + const auto shownOfProject = progressOf(projectId).size(); + const auto shownOfCopy = progressOf(copyId).size(); + + tracker_->stop(projectId); + ASSERT_TRUE(tracker_->discard(opened->instanceId, opened->localPath).has_value()); + onFileProgress(fileProgress(file, 13u, 13u, /*transferred=*/true)); + onCopyProgress(fileProgress(opened->localPath.native(), 12u, 12u, /*transferred=*/true)); + runFor(std::chrono::milliseconds{500}); + completeUploads(); + runFor(std::chrono::milliseconds{300}); + + EXPECT_EQ(progressOf(projectId).size(), shownOfProject); + EXPECT_EQ(progressOf(copyId).size(), shownOfCopy); } TEST_F(TrackerTest, AnAnswerToAnUnknownConflictChangesNothing) @@ -1272,12 +1607,39 @@ namespace Test ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); const auto mainConflict = conflictIdAt(main_); const auto utilConflict = conflictIdAt(util_); + const auto watchId = onlyEntry().watchId; + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.waitingForAnswer == 2u; + } + )); + const auto shown = view_->watchProgress.size(); tracker_->connectionLost(); EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(view_->removedConflicts, (std::vector{mainConflict.value(), utilConflict.value()})); - EXPECT_EQ(lastProgress().waitingForAnswer, 0u); + // The view drops the progress with the entry. + runFor(std::chrono::milliseconds{300}); + EXPECT_EQ(view_->watchProgress.size(), shown); + } + + TEST_F(TrackerConflictsTest, TheProgressCountsTheChangesWaitingForAnAnswerFromTheConflicts) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto watchId = onlyEntry().watchId; + + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 2u, .waitingForAnswer = 2u, .synced = 4u})); + EXPECT_EQ(lastProgressOf(watchId)->changesWaiting, 2u); + tracker_->answerConflict(conflictIdAt(main_), SharedData::ConflictResolution::KeepServers); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u, .done = 1u, .waitingForAnswer = 1u, .synced = 4u})); + tracker_->answerConflict(conflictIdAt(util_), SharedData::ConflictResolution::ReplaceOnServer); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u, .done = 1u, .synced = 4u})); + EXPECT_EQ(lastProgressOf(watchId)->changesWaiting, 1u); + completeUploads(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 2u})); } TEST_F(TrackerConflictsTest, AConflictWhosePathWasDeletedLocallyBeforeTheAnswerUploadsNothing) @@ -1790,7 +2152,8 @@ namespace Test ASSERT_TRUE( tracker_->uploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv/notes.txt")).has_value() ); - ASSERT_TRUE(runUntilProgress({.pending = 1})); + ASSERT_TRUE(runUntilProgress(watchIdNamed("notes.txt"), {.pending = 1u})); + const auto shown = view_->watchProgress.size(); tracker_->connectionLost(); write(root_ / "local" / "notes.txt", "changed locally"); @@ -1804,7 +2167,7 @@ namespace Test const auto names = view_->ended.front().entries | std::views::transform(&SharedData::TrackedEntry::name) | std::ranges::to(); EXPECT_EQ(names, (std::set{"copy.txt", "notes.txt"})); - EXPECT_EQ(lastProgress(), SharedData::TrackerProgress{}); + EXPECT_EQ(view_->watchProgress.size(), shown); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); EXPECT_EQ(uploadsTo("/srv/copy.txt"), 0u); EXPECT_EQ(instances_->findInstance(copy->instanceId.value()), nullptr); @@ -2674,8 +3037,9 @@ namespace Test { const auto project = root_ / "local" / "project"; ASSERT_TRUE(tracker_->uploadAndWatch(local(project), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; completeEverything(); - ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 3u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 3u})); const auto staging = root_ / "staging"; std::filesystem::create_directories(staging / "sub"); write(staging / "one.txt", "one"); @@ -2683,17 +3047,18 @@ namespace Test std::filesystem::rename(staging, project / "moved"); - ASSERT_TRUE(runUntilProgress({.pending = 4u, .done = 0u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 4u, .synced = 3u})); completeUploads(); - ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 4u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 4u})); runFor(std::chrono::milliseconds{500}); - EXPECT_EQ(lastProgress(), (SharedData::TrackerProgress{.pending = 0u, .done = 4u, .waitingForAnswer = 0u})); + EXPECT_EQ(countsOf(*lastProgressOf(watchId)), ProgressCounts{.synced = 4u}); } TEST_F(TrackerTest, TheProgressCountsANewDirectoryAndTheLateReportsOfItsContentOnce) { const auto project = root_ / "local" / "project"; ASSERT_TRUE(tracker_->uploadAndWatch(local(project), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; completeEverything(); std::filesystem::create_directory(project / "new"); @@ -2708,16 +3073,11 @@ namespace Test write(project / "new" / "three.txt", "three"); runFor(std::chrono::milliseconds{1000}); completeUploads(); - ASSERT_TRUE(runUntil( - [this]() - { - return lastProgress().pending == 0u; - } - )); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 4u})); runFor(std::chrono::milliseconds{500}); completeEverything(); - EXPECT_EQ(lastProgress(), (SharedData::TrackerProgress{.pending = 0u, .done = 4u, .waitingForAnswer = 0u})); + EXPECT_EQ(countsOf(*lastProgressOf(watchId)), ProgressCounts{.synced = 4u}); } TEST_F(TrackerTest, TheProgressCountsWhatADirectoryUploadLeftNotChangesThatCameToNothing) @@ -2725,8 +3085,9 @@ namespace Test const auto project = root_ / "local" / "project"; const auto staging = root_ / "staging"; ASSERT_TRUE(tracker_->uploadAndWatch(local(project), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; completeEverything(); - ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 3u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 3u})); std::filesystem::create_directory(staging); write(staging / "one.txt", "one"); @@ -2748,22 +3109,24 @@ namespace Test runFor(std::chrono::milliseconds{500}); completeEverything(); - EXPECT_TRUE(runUntilProgress({.pending = 0u, .done = 2u, .waitingForAnswer = 0u})); + EXPECT_TRUE(runUntilProgress(watchId, {.synced = 2u})); EXPECT_TRUE(remote_->of("remove").empty()); } - TEST_F(TrackerTest, TheProgressShowsAtOnceThatNothingIsOnItsWayAnyMore) + TEST_F(TrackerTest, TheProgressShowsAtOnceThatNothingIsOnItsWayAnyMoreOnceTheIntervalPassed) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; write(opened->localPath.native(), "changed locally"); - ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u})); + runFor(std::chrono::milliseconds{250}); completeUploads(); ioc_.restart(); ioc_.run_for(std::chrono::milliseconds{50}); - EXPECT_EQ(lastProgress(), (SharedData::TrackerProgress{.pending = 0u, .done = 1u, .waitingForAnswer = 0u})); + EXPECT_EQ(countsOf(*lastProgressOf(watchId)), ProgressCounts{.synced = 1u}); EXPECT_FALSE(onlyEntry().uploading); } @@ -3047,16 +3410,17 @@ namespace Test { const auto file = root_ / "local" / "project" / "big.bin"; ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; completeEverything(); runFor(std::chrono::milliseconds{500}); write(file, "large content"); - ASSERT_TRUE(runUntilProgress({.pending = 1u, .done = 0u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u, .synced = 3u})); std::filesystem::remove(file); ASSERT_TRUE(runUntilStopped()); endStoppedUploads(); - EXPECT_TRUE(runUntilProgress({.pending = 0u, .done = 1u, .waitingForAnswer = 0u})); + EXPECT_TRUE(runUntilProgress(watchId, {.synced = 1u})); } TEST_F(TrackerTest, StoppingAWatchLeavesItsRunningUploadAlone) @@ -3824,4 +4188,56 @@ namespace Test tracker_->retryFailure(failureId); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); } + + TEST_F(TrackerFailuresTest, TheUnsentBytesOfAFailedUploadLeaveTheTotalSoTheProgressStillEndsFull) + { + ASSERT_NO_FATAL_FAILURE(watchProject()); + const auto watchId = onlyEntry().watchId; + save(main_, std::string(100u, 'm'), "/srv/project/src/main.cpp", 1u); + save(util_, std::string(50u, 'u'), "/srv/project/src/util.cpp", 1u); + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.changesPending == 2u && progress.bytesTotal == 150u; + } + )); + lastUploadTo("/srv/project/src/main.cpp")->onUploadProgress(fileProgress(main_, 30u, 100u)); + lastUploadTo("/srv/project/src/util.cpp")->onUploadProgress(fileProgress(util_, 10u, 50u)); + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.bytesDone == 40u && progress.bytesTotal == 150u; + } + )); + + endAs(*lastUploadTo("/srv/project/src/main.cpp"), failedAs(SharedData::UploadFailureReason::ConnectionProblem)); + + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.bytesTotal == 80u; + } + )); + const auto failed = *lastProgressOf(watchId); + EXPECT_EQ(failed.bytesDone, 40u); + EXPECT_EQ(countsOf(failed), (ProgressCounts{.pending = 1u, .done = 1u, .synced = 4u})); + EXPECT_EQ(inFlightPathsOf(failed), std::vector{util_}); + lastUploadTo("/srv/project/src/util.cpp") + ->onUploadProgress(fileProgress(util_, 50u, 50u, /*transferred=*/true)); + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.bytesDone == 80u; + } + )); + EXPECT_EQ(lastProgressOf(watchId)->bytesTotal, 80u); + completeUploads(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 2u})); + EXPECT_EQ(lastProgressOf(watchId)->lastSync->bytes, 80u); + EXPECT_EQ(view_->failures.size(), 1u); + } } From e951b688411008092351a762f7e8ce40fec63e7c Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:15:18 +0200 Subject: [PATCH 077/114] Listed @lumino/virtualdom, which the dock tab badge renderer imports --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 4b20e5df..a97fd3b3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@lumino/default-theme": "^2.1.12", "@lumino/dragdrop": "^2.1.7", "@lumino/messaging": "^2.0.4", + "@lumino/virtualdom": "^2.0.4", "@lumino/widgets": "^2.7.3", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", From 1657210d4b1482d5cf84b3eaf6622f4571becf8e Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:23:42 +0200 Subject: [PATCH 078/114] Added a pending selection that waits for the listing of its directory --- frontend/test/frontend/main.cpp | 1 + .../test/frontend/test_pending_selection.hpp | 85 +++++++++++++++++++ .../side/pending_selection.hpp | 71 ++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 frontend/test/frontend/test_pending_selection.hpp create mode 100644 nui-file-explorer/include/nui-file-explorer/side/pending_selection.hpp diff --git a/frontend/test/frontend/main.cpp b/frontend/test/frontend/main.cpp index d886b323..fb37b288 100644 --- a/frontend/test/frontend/main.cpp +++ b/frontend/test/frontend/main.cpp @@ -6,6 +6,7 @@ #include "test_item_order.hpp" #include "test_numbered_saves.hpp" #include "test_overwrite_decisions.hpp" +#include "test_pending_selection.hpp" #include "test_selection_model.hpp" #include "test_side_paths.hpp" #include "test_tracking_model.hpp" diff --git a/frontend/test/frontend/test_pending_selection.hpp b/frontend/test/frontend/test_pending_selection.hpp new file mode 100644 index 00000000..6ccde4fc --- /dev/null +++ b/frontend/test/frontend/test_pending_selection.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include + +#include + +#include +#include + +namespace NuiFileExplorer::Test +{ + TEST(PendingSelectionTests, NothingIsPendingAtFirst) + { + PendingSelection selection{}; + + EXPECT_FALSE(selection.isPending()); + EXPECT_EQ(selection.take("/home"), std::nullopt); + } + + TEST(PendingSelectionTests, TheListingOfTheDirectoryYieldsTheItemPath) + { + PendingSelection selection{}; + selection.request("/home/user", "notes.txt"); + + EXPECT_TRUE(selection.isPending()); + EXPECT_EQ(selection.take("/home/user"), std::filesystem::path{"/home/user/notes.txt"}); + EXPECT_FALSE(selection.isPending()); + } + + TEST(PendingSelectionTests, AnItemOfTheRootJoinsWithoutASecondSeparator) + { + PendingSelection selection{}; + selection.request("/", "etc"); + + EXPECT_EQ(selection.take("/"), std::filesystem::path{"/etc"}); + } + + TEST(PendingSelectionTests, TheRequestIsTakenOnlyOnce) + { + PendingSelection selection{}; + selection.request("/home/user", "notes.txt"); + selection.take("/home/user"); + + EXPECT_EQ(selection.take("/home/user"), std::nullopt); + } + + TEST(PendingSelectionTests, TheListingOfAnotherDirectoryEndsTheRequest) + { + PendingSelection selection{}; + selection.request("/home/user", "notes.txt"); + + EXPECT_EQ(selection.take("/home"), std::nullopt); + EXPECT_FALSE(selection.isPending()); + EXPECT_EQ(selection.take("/home/user"), std::nullopt); + } + + TEST(PendingSelectionTests, MovingToTheDirectoryKeepsTheRequest) + { + PendingSelection selection{}; + selection.request("/home/user", "notes.txt"); + selection.onPathChanged("/home/user"); + + EXPECT_EQ(selection.take("/home/user"), std::filesystem::path{"/home/user/notes.txt"}); + } + + TEST(PendingSelectionTests, MovingElsewhereCancelsTheRequest) + { + PendingSelection selection{}; + selection.request("/home/user", "notes.txt"); + selection.onPathChanged("/tmp"); + selection.onPathChanged("/home/user"); + + EXPECT_FALSE(selection.isPending()); + EXPECT_EQ(selection.take("/home/user"), std::nullopt); + } + + TEST(PendingSelectionTests, ALaterRequestReplacesTheEarlierOne) + { + PendingSelection selection{}; + selection.request("/home/user", "notes.txt"); + selection.request("/srv", "data"); + + EXPECT_EQ(selection.take("/srv"), std::filesystem::path{"/srv/data"}); + } +} diff --git a/nui-file-explorer/include/nui-file-explorer/side/pending_selection.hpp b/nui-file-explorer/include/nui-file-explorer/side/pending_selection.hpp new file mode 100644 index 00000000..7b626a1c --- /dev/null +++ b/nui-file-explorer/include/nui-file-explorer/side/pending_selection.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include + +namespace NuiFileExplorer +{ + /** + * @brief An item of a directory to select once the listing of that directory has loaded. + * + * Moving to another directory forgets the request, so does the first listing of its directory, whether the item + * is in it or not. + */ + class PendingSelection + { + public: + /** + * @brief Remembers to select the item @p name of @p directory, replacing an earlier request. + * + * @param directory The directory as the side model shows it, after navigating there. + * @param name The file name of the item in @p directory. + */ + void request(std::filesystem::path directory, std::filesystem::path name) + { + request_ = Request{.directory = std::move(directory), .name = std::move(name)}; + } + + /** + * @brief Forgets the request when @p path, the directory the side moved to, is not its directory. + */ + void onPathChanged(std::filesystem::path const& path) + { + if (request_ && request_->directory != path) + request_.reset(); + } + + /** + * @brief Ends the request on a listing of @p listedDirectory. + * + * @return The full path of the item to select when @p listedDirectory is the directory of the request, + * nothing otherwise. + */ + std::optional take(std::filesystem::path const& listedDirectory) + { + return std::exchange(request_, std::nullopt) + .and_then( + [&listedDirectory](Request const& request) -> std::optional + { + if (request.directory != listedDirectory) + return std::nullopt; + return request.directory / request.name; + } + ); + } + + bool isPending() const + { + return request_.has_value(); + } + + private: + struct Request + { + std::filesystem::path directory; + std::filesystem::path name; + }; + + std::optional request_{}; + }; +} From dfd6f9104ecbb43c3925ed53bac7572a7bc9bb04 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:23:42 +0200 Subject: [PATCH 079/114] Let a file explorer side navigate and select an item once it is listed - Side::navigateAndSelect(directory, name) selects like a click after the listing, a later navigation cancels it - SelectionManager::jumpTo always scrolls, the last scrolled index can be of an earlier listing --- .../include/nui-file-explorer/side.hpp | 17 +++++++++++++ .../side/selection_manager.hpp | 9 ++++++- .../side/side_implementation.hpp | 2 ++ .../source/nui-file-explorer/side.cpp | 25 ++++++++++++++++++- .../side/selection_manager.cpp | 13 +++++++++- 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/nui-file-explorer/include/nui-file-explorer/side.hpp b/nui-file-explorer/include/nui-file-explorer/side.hpp index fa1b5779..5e4630c1 100644 --- a/nui-file-explorer/include/nui-file-explorer/side.hpp +++ b/nui-file-explorer/include/nui-file-explorer/side.hpp @@ -180,6 +180,17 @@ namespace NuiFileExplorer */ void path(std::filesystem::path const& path); + /** + * @brief Navigates to @p directory and selects the item @p name there once the listing has loaded, as a + * single click on it does: the selection is replaced, the keyboard continues from the item, the item + * is scrolled into view and the side takes the focus. Navigating elsewhere before the listing arrives + * cancels the selection, a listing without the item selects nothing. + * + * @param directory The directory to show. + * @param name The file name of the item in @p directory. + */ + void navigateAndSelect(std::filesystem::path const& directory, std::filesystem::path const& name); + /** * @brief Returns the current path. * @@ -229,6 +240,12 @@ namespace NuiFileExplorer */ void clearNavigationLoading(); + /** + * @brief Selects the item requested by @ref navigateAndSelect when the listing just shown is of its + * directory, and ends the request. + */ + void selectPendingItem(); + /** * @brief Commit the search box value as a filter that hides non-matching items. * Used only in paginated mode; for small directories the live highlight path diff --git a/nui-file-explorer/include/nui-file-explorer/side/selection_manager.hpp b/nui-file-explorer/include/nui-file-explorer/side/selection_manager.hpp index bbe63a84..3a6261ef 100644 --- a/nui-file-explorer/include/nui-file-explorer/side/selection_manager.hpp +++ b/nui-file-explorer/include/nui-file-explorer/side/selection_manager.hpp @@ -93,9 +93,16 @@ namespace NuiFileExplorer /// Jump the cursor to @p index as if it were a plain click: clears the ctrl mask, /// collapses the range to a single-item selection at @p index, and scrolls it - /// into view. Used by type-ahead. + /// into view, also when it was the last item scrolled to. Used by type-ahead. void jumpTo(std::size_t index); + /** + * @brief Jumps to the selectable item at @p path as @ref jumpTo(std::size_t) does. + * + * @return Whether there is such an item. + */ + bool jumpTo(std::filesystem::path const& path); + /** * @brief Ctrl+box-drag: adds the selectable items to the mask without touching the range. */ diff --git a/nui-file-explorer/include/nui-file-explorer/side/side_implementation.hpp b/nui-file-explorer/include/nui-file-explorer/side/side_implementation.hpp index d81a4d1c..da08afc0 100644 --- a/nui-file-explorer/include/nui-file-explorer/side/side_implementation.hpp +++ b/nui-file-explorer/include/nui-file-explorer/side/side_implementation.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -65,6 +66,7 @@ namespace NuiFileExplorer Nui::Observed showLoadingHint{false}; Nui::val loadingHintTimerHandle{Nui::val::undefined()}; Nui::ListenRemover> currentPathListener{}; + PendingSelection pendingSelection{}; // Pagination: render-only slicing. SelectionManager remains index-absolute against // the full items vector. Default pageSize is intentionally large (the user prefers diff --git a/nui-file-explorer/source/nui-file-explorer/side.cpp b/nui-file-explorer/source/nui-file-explorer/side.cpp index dc9ab55c..9b4395f1 100644 --- a/nui-file-explorer/source/nui-file-explorer/side.cpp +++ b/nui-file-explorer/source/nui-file-explorer/side.cpp @@ -55,7 +55,8 @@ namespace NuiFileExplorer // listing arrives, so the listener only has to mark the start. impl_->currentPathListener = Nui::smartListen( impl_->model->currentPath(), - [this](auto const&) { + [this](auto const& path) { + impl_->pendingSelection.onPathChanged(path); beginNavigationLoading(); } ); @@ -434,6 +435,7 @@ namespace NuiFileExplorer } Nui::globalEventContext.executeActiveEventsImmediately(); + selectPendingItem(); } void Side::beginNavigationLoading() @@ -698,6 +700,27 @@ namespace NuiFileExplorer // Do NOT call onPathChange here. That is intentional. } + void Side::navigateAndSelect(std::filesystem::path const& directory, std::filesystem::path const& name) + { + model().navigateTo(directory); + impl_->pendingSelection.request(model().currentPath().value(), name); + } + + void Side::selectPendingItem() + { + const auto itemPath = impl_->pendingSelection.take(model().currentPath().value()); + if (!itemPath || !impl_->selectionManager.jumpTo(*itemPath)) + return; + closeMenus(); + if (const auto sideElement = impl_->sideElement.lock(); sideElement) + { + auto options = Nui::val::object(); + options.set("preventScroll", true); + sideElement->val().call("focus", options); + } + Nui::globalEventContext.executeActiveEventsImmediately(); + } + std::filesystem::path Side::path() { return model().currentPath().value(); diff --git a/nui-file-explorer/source/nui-file-explorer/side/selection_manager.cpp b/nui-file-explorer/source/nui-file-explorer/side/selection_manager.cpp index caf955ab..45604653 100644 --- a/nui-file-explorer/source/nui-file-explorer/side/selection_manager.cpp +++ b/nui-file-explorer/source/nui-file-explorer/side/selection_manager.cpp @@ -205,7 +205,18 @@ namespace NuiFileExplorer return; selection_.collapseTo(index); rebuildSelection(); - maybeScrollActiveIntoView(); + lastScrolledIndex_ = index; + if (scrollIntoView_) + scrollIntoView_(index); + } + + bool SelectionManager::jumpTo(std::filesystem::path const& path) + { + const auto index = itemIndex(path); + if (!index || !isSelectablePath(*index)) + return false; + jumpTo(*index); + return true; } void SelectionManager::ctrlAddRange(std::size_t begin, std::size_t endInclusive) From 176cb7baaa1968593f100ce3ad05e6feb5bd45ff Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:23:42 +0200 Subject: [PATCH 080/114] Selected the item in the remote side on Reveal on Server --- frontend/source/frontend/session.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/source/frontend/session.cpp b/frontend/source/frontend/session.cpp index b650644f..1745a6a4 100644 --- a/frontend/source/frontend/session.cpp +++ b/frontend/source/frontend/session.cpp @@ -217,7 +217,11 @@ struct Session::Implementation auto* const side = fileExplorerPanel.remoteFileGridSide(); if (side == nullptr) return; - side->path(path.parent().value_or(path).value()); + const auto parent = path.parent(); + const auto name = path.filename(); + if (!parent || !name) + return side->path(path.value()); + side->navigateAndSelect(parent->value(), name->value()); } ); } From e065f2bcf28fc9c4008a960f85f67ea92cf3b749 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:24:42 +0200 Subject: [PATCH 081/114] Brought the File Explorer tab to the front on Reveal on Server --- frontend/source/frontend/session.cpp | 1 + static/source/content_panel.ts | 11 +++++++++++ static/source/content_panel_manager.ts | 3 +++ 3 files changed, 15 insertions(+) diff --git a/frontend/source/frontend/session.cpp b/frontend/source/frontend/session.cpp index 1745a6a4..9e0112d6 100644 --- a/frontend/source/frontend/session.cpp +++ b/frontend/source/frontend/session.cpp @@ -217,6 +217,7 @@ struct Session::Implementation auto* const side = fileExplorerPanel.remoteFileGridSide(); if (side == nullptr) return; + Nui::val::global("contentPanelManager").call("activateFileExplorer", sessionLayoutId); const auto parent = path.parent(); const auto name = path.filename(); if (!parent || !name) diff --git a/static/source/content_panel.ts b/static/source/content_panel.ts index 422f2375..785c131f 100644 --- a/static/source/content_panel.ts +++ b/static/source/content_panel.ts @@ -178,6 +178,17 @@ export class ContentPanel { } } + /** Brings the first File Explorer tab to the front. Returns false when the panel has none. */ + activateFileExplorer(): boolean { + for (const widget of this.dock.widgets()) { + if (widget instanceof FileExplorer) { + this.dock.selectWidget(widget); + return true; + } + } + return false; + } + /** Re-applies the translated titles to every tab that has no title of its own. */ relabel() { for (const widget of this.dock.widgets()) { diff --git a/static/source/content_panel_manager.ts b/static/source/content_panel_manager.ts index 810ed1d9..e08a40fc 100644 --- a/static/source/content_panel_manager.ts +++ b/static/source/content_panel_manager.ts @@ -226,6 +226,9 @@ class ContentPanelManager { this.pendingFileTrackingAttention.delete(panelId); } + /** Brings the File Explorer tab of a panel to the front, for Reveal on Server. */ + activateFileExplorer = (panelId: string): boolean => this.panels.get(panelId)?.activateFileExplorer() ?? false; + renameTerminalById = (panelId: string, channelId: ChannelId, title: string) => { const panel = this.panels.get(panelId); if (!panel) { From b78bd88bf9e8bfd36a94fdcfbc83080a6f9c1a34 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:35:34 +0200 Subject: [PATCH 082/114] Kept stopped watches listed with their reason until the user removes them - Stop, a move onto another watch's server path, a copy gone and a lost connection retire the watch: its conflicts and failures leave the view, then its last progress, then the entry with stop set; Overlap toasts too - Stopped watches claim no remote path, stopAll keeps none, removeStopped and removeAllStopped take them off the view - The Overlap stop names the other watch and the would-be paths and logs plain text - Adapted the stop, copy gone and lost connection tests: the entry stays as stopped and shows one last progress --- .../include/backend/file_tracking/tracker.hpp | 11 +- .../source/backend/file_tracking/tracker.cpp | 226 ++++++++--- backend/test/backend/test_tracker.hpp | 376 +++++++++++++++++- 3 files changed, 544 insertions(+), 69 deletions(-) diff --git a/backend/include/backend/file_tracking/tracker.hpp b/backend/include/backend/file_tracking/tracker.hpp index 2b613b41..517e8304 100644 --- a/backend/include/backend/file_tracking/tracker.hpp +++ b/backend/include/backend/file_tracking/tracker.hpp @@ -145,7 +145,8 @@ namespace FileTracking uploadAndWatch(Paths::LocalPath const& item, Paths::RemotePath const& destination); /** - * @brief Ends the watch @p watchId: a downloaded copy's instance ends with it, an uploaded original stays. + * @brief Ends the watch @p watchId: a downloaded copy's instance ends with it, an uploaded original stays. The + * view keeps showing it as stopped by the user until removeStopped(), it claims no remote path. */ void stop(Ids::WatchId const& watchId); @@ -226,13 +227,15 @@ namespace FileTracking /** * @brief Ends every watch without telling the view, for a frontend that is gone or a session that closes. The * instances of the copies it downloaded, watched or only opened, end too: they count as orphaned then, - * or go when nothing is left in them. A download on its way is dropped, it opens nothing. + * or go when nothing is left in them. A download on its way is dropped, it opens nothing. No stopped + * watch is kept. */ void stopAll(); /** - * @brief The session lost its connection: every watch ends like in stopAll(), either side may change until it - * is back, and the view shows why. The user watches anew. + * @brief The session lost its connection: every watch stops like in stop(), the view shows them as stopped + * for that and why, either side may change until it is back. The rest ends like in stopAll(). The user + * watches anew. */ void connectionLost(); diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 294c8d34..c10eb49a 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -221,6 +222,11 @@ namespace FileTracking return static_cast(seconds.count()); } + SharedData::WatchStop watchStopOf(SharedData::WatchStopReason reason) + { + return {.reason = reason, .stoppedAt = secondsSinceEpochNow()}; + } + /** * @brief What the server has of a file an upload sent in @p sent: uploads carry the modification time along, * in seconds. @@ -298,6 +304,18 @@ namespace FileTracking }; } + /** + * @brief The remote path of another watch, or of one that starts, that a remote path overlaps. + */ + struct RemoteOverlap + { + Paths::RemotePath remotePath; + /** @brief Nothing for a watch that starts, see Implementation::claimRemote(). */ + std::optional watchId{}; + /** @brief The name of its item, nothing for a watch that starts. */ + std::optional watchName{}; + }; + /** * @brief Whether @p option may be on for a downloaded copy: removing or renaming a temporary copy never touches * the server's file. @@ -476,6 +494,8 @@ namespace FileTracking std::function defaultOptions; std::map entries{}; + /** @brief The watches that stopped, as the view shows them until they are removed, see retire(). */ + std::map stopped{}; /** @brief The instances of downloads on their way, see open(). */ std::set downloading{}; /** @brief The instances of copies opened without a watch, they end with the tracker, see stopAll(). */ @@ -771,24 +791,39 @@ namespace FileTracking std::optional const& ignoredWatchId = std::nullopt ) const { - auto taken = claims | std::views::values | std::ranges::to(); - for (auto const& entry : std::views::values(entries)) - if (entry.watchId != ignoredWatchId) - taken.push_back(entry.remotePath); - const auto overlapping = std::ranges::find_if( - taken, - [&remotePath](Paths::RemotePath const& other) - { - return remotePath.isWithin(other) || other.isWithin(remotePath); - } - ); - if (overlapping == taken.end()) + const auto overlap = remoteOverlapOf(remotePath, ignoredWatchId); + if (!overlap) return {}; return std::unexpected{Utility::localizedMessage( - "backend.fileTracking.remoteOverlaps", remotePath.value(), overlapping->value() + "backend.fileTracking.remoteOverlaps", remotePath.value(), overlap->remotePath.value() )}; } + /** + * @brief What @p remotePath overlaps, see refuseRemoteOverlap(). Stopped watches claim nothing. + */ + std::optional remoteOverlapOf( + Paths::RemotePath const& remotePath, + std::optional const& ignoredWatchId = std::nullopt + ) const + { + const auto overlaps = [&remotePath](Paths::RemotePath const& other) + { + return remotePath.isWithin(other) || other.isWithin(remotePath); + }; + for (auto const& entry : std::views::values(entries)) + if (entry.watchId != ignoredWatchId && overlaps(entry.remotePath)) + return RemoteOverlap{ + .remotePath = entry.remotePath, + .watchId = entry.watchId, + .watchName = shownNameOf(entry.localPath), + }; + for (auto const& claimed : std::views::values(claims)) + if (overlaps(claimed)) + return RemoteOverlap{.remotePath = claimed}; + return std::nullopt; + } + std::expected, std::string> claimRemote(Paths::RemotePath const& remotePath) { return refuseRemoteOverlap(remotePath) @@ -1010,11 +1045,49 @@ namespace FileTracking void stop(Ids::WatchId const& watchId) { - const auto instanceId = forget(watchId); - if (!instanceId) - return; - releaseInstance(*instanceId); - view->showOrphanedCount(instances->countOrphaned()); + const auto retired = retire(watchId, watchStopOf(SharedData::WatchStopReason::StoppedByUser)); + if (retired && retired->instanceId) + view->showOrphanedCount(instances->countOrphaned()); + } + + /** + * @brief Ends the watch @p watchId like forget(), but the view keeps showing it as stopped by @p stop until + * removeStopped(). A downloaded copy's instance ends, and the watch claims no remote path any more. + * @return The stopped entry as the view shows it, nothing when there is no such watch. + */ + std::optional retire(Ids::WatchId watchId, SharedData::WatchStop stop) + { + if (!entries.contains(watchId)) + return std::nullopt; + Log::info("FileTracking: stopping watch {}: {}", watchId.value(), Utility::enumToString(stop.reason)); + auto shown = endWatch(watchId, stop.stoppedAt); + shown.stop = std::move(stop); + if (shown.instanceId) + releaseInstance(*shown.instanceId); + view->showEntry(shown); + stopped.insert_or_assign(watchId, shown); + return shown; + } + + /** + * @brief Ends the watch @p watchId, which exists: its conflicts and failures leave the view, which hears of its + * last progress, its burst ends at @p endedAt. What is on its way goes on unshown. + * @return The entry as the view shows it once it ended. + */ + SharedData::TrackedEntry endWatch(Ids::WatchId const& watchId, std::uint64_t endedAt) + { + auto node = entries.extract(watchId); + auto& entry = node.mapped(); + dropProblems(watchId); + entry.progress.dropPending(); + entry.progress.finishBurst(endedAt); + view->showWatchProgress(entry.progress.snapshot()); + auto shown = shownEntryOf(entry); + shown.uploading = false; + // The item may be gone or elsewhere by now, its watch knows what it was. + if (auto const* const watch = localWatches->find(watchId)) + shown.isDirectory = watch->isDirectory(); + return shown; } /** @@ -1033,25 +1106,31 @@ namespace FileTracking } /** - * @brief Ends the watch @p watchId and takes its entry off the view. - * @return The instance of a downloaded copy, nothing for an uploaded original or no such watch. + * @brief Ends the watch @p watchId and takes its entry off the view. The instance of a downloaded copy stays. */ - std::optional forget(Ids::WatchId const& watchId) + void forget(Ids::WatchId const& watchId) { auto node = entries.extract(watchId); if (node.empty()) - return std::nullopt; - Log::info("FileTracking: stopping watch {}", watchId.value()); + return; + Log::info("FileTracking: forgetting watch {}", watchId.value()); + dropProblems(watchId); + view->removeEntry(watchId); + } + + /** + * @brief Drops the conflicts and failures of the watch @p watchId and takes them off the view, conflicts + * first. The uploads that waited for an answer leave the server order. + */ + void dropProblems(Ids::WatchId const& watchId) + { ShownProblems shown; collectShownProblems(watchId, shown); const auto dropped = conflicts.removeWatch(watchId); failures.removeWatch(watchId); hideProblems(shown); - view->removeEntry(watchId); for (auto const& listed : dropped) leaveOrder(listed.context); - const auto* copy = node.mapped().downloadedCopy(); - return copy ? std::optional{copy->instanceId} : std::nullopt; } void collectShownProblems(Ids::WatchId const& watchId, ShownProblems& shown) const @@ -1108,6 +1187,16 @@ namespace FileTracking } void stopAll() + { + endEverything(); + stopped.clear(); + } + + /** + * @brief Ends every watch, opened copy and download without telling the view, see Tracker::stopAll(). The + * stopped watches stay. + */ + void endEverything() { auto copies = std::exchange(openedCopies, {}); copies.merge(std::exchange(downloading, {})); @@ -1131,18 +1220,17 @@ namespace FileTracking void connectionLost() { + Log::info("FileTracking: the connection was lost, ending {} watch(es)", entries.size()); + // Nothing that waits in the server order starts any more. + const auto waiting = std::exchange(waitingOperations, {}); + const auto stop = watchStopOf(SharedData::WatchStopReason::ConnectionLost); auto ended = SharedData::EndedWatches{.reason = SharedData::WatchEndReason::ConnectionLost, .entries = {}}; - ShownProblems shown; - for (auto const& entry : std::views::values(entries)) - { - ended.entries.push_back(shownEntryOf(entry)); - collectShownProblems(entry.watchId, shown); - } - Log::info("FileTracking: the connection was lost, ending {} watch(es)", ended.entries.size()); - stopAll(); - hideProblems(shown); - for (auto const& endedEntry : ended.entries) - view->removeEntry(endedEntry.watchId); + for (auto const& watchId : std::views::keys(entries) | std::ranges::to()) + if (auto retired = retire(watchId, stop)) + ended.entries.push_back(*std::move(retired)); + endEverything(); + for (auto const& ticket : std::views::keys(waiting)) + leaveOrder(ticket); if (!ended.entries.empty()) view->showEnded(ended); view->showOrphanedCount(instances->countOrphaned()); @@ -1530,11 +1618,11 @@ namespace FileTracking entry->localPath.toUtf8(), watchId.value() ); - const auto ended = SharedData::EndedWatches{ - .reason = SharedData::WatchEndReason::CopyGone, .entries = {shownEntryOf(*entry)} - }; - stop(watchId); - view->showEnded(ended); + auto retired = retire(watchId, watchStopOf(SharedData::WatchStopReason::CopyGone)); + if (!retired) + return; + view->showEnded({.reason = SharedData::WatchEndReason::CopyGone, .entries = {*std::move(retired)}}); + view->showOrphanedCount(instances->countOrphaned()); } /** @@ -1554,16 +1642,8 @@ namespace FileTracking return; } // The server copy may not follow onto the path of another watch, which writes there. - if (const auto free = refuseRemoteOverlap(*newRemotePath, entry.watchId); !free) - { - Log::warn( - "FileTracking: stopping watch {}, its move overlaps another watch: {}", - entry.watchId.value(), - free.error() - ); - const auto watchId = entry.watchId; - return stop(watchId); - } + if (const auto overlap = remoteOverlapOf(*newRemotePath, entry.watchId)) + return stopOverlapping(entry.watchId, newPath, *newRemotePath, *overlap); Log::info( "FileTracking: watch {} moved from '{}' to '{}'", entry.watchId.value(), @@ -1587,6 +1667,35 @@ namespace FileTracking show(entry); } + /** + * @brief The item of the watch @p watchId moved to @p movedTo, its server copy would follow to @p wouldGoTo, + * which @p overlap holds or lies below: the watch stops, its server copy stays where it is. + */ + void stopOverlapping( + Ids::WatchId watchId, + Paths::LocalPath const& movedTo, + Paths::RemotePath const& wouldGoTo, + RemoteOverlap const& overlap + ) + { + Log::warn( + "FileTracking: stopping watch {}, its item moved to '{}' and its server copy would go to '{}', which " + "overlaps '{}' of another watch", + watchId.value(), + movedTo.toUtf8(), + wouldGoTo.value(), + overlap.remotePath.value() + ); + auto stop = watchStopOf(SharedData::WatchStopReason::Overlap); + stop.otherWatchId = overlap.watchId; + stop.otherWatchName = overlap.watchName; + stop.movedTo = movedTo; + stop.wouldGoTo = wouldGoTo; + auto retired = retire(watchId, std::move(stop)); + if (retired) + view->showEnded({.reason = SharedData::WatchEndReason::Overlap, .entries = {*std::move(retired)}}); + } + void onMoved(Entry& entry, Paths::LocalPath const& oldPath, LocalChange const& change) { const auto oldRemotePath = remotePathOf(entry, oldPath); @@ -2285,12 +2394,21 @@ namespace FileTracking void removeStopped(Ids::WatchId const& watchId) { - Log::warn("FileTracking: removing the stopped watch {} is not implemented", watchId.value()); + if (stopped.erase(watchId) == 0) + { + Log::warn("FileTracking: there is no stopped watch {} to remove", watchId.value()); + return; + } + Log::info("FileTracking: removing the stopped watch {}", watchId.value()); + view->removeEntry(watchId); } void removeAllStopped() { - Log::warn("FileTracking: removing the stopped watches is not implemented"); + const auto removed = std::exchange(stopped, {}); + Log::info("FileTracking: removing {} stopped watch(es)", removed.size()); + for (auto const& watchId : std::views::keys(removed)) + view->removeEntry(watchId); } void listOrphanedCopies() diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 81118470..cd44ff55 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include "error_log_capture.hpp" @@ -186,17 +187,23 @@ namespace Test void showEntry(SharedData::TrackedEntry const& entry) override { entries.insert_or_assign(entry.watchId.value(), entry); + events.push_back( + "entry " + entry.watchId.value() + + (entry.stop ? " " + Utility::enumToString(entry.stop->reason) : std::string{}) + ); } void removeEntry(Ids::WatchId const& watchId) override { entries.erase(watchId.value()); removed.push_back(watchId.value()); + events.push_back("removeEntry " + watchId.value()); } void showEnded(SharedData::EndedWatches const& shown) override { ended.push_back(shown); + events.push_back("ended " + Utility::enumToString(shown.reason)); } void ask(SharedData::ServerChangedQuestion const& question) override @@ -207,6 +214,7 @@ namespace Test void showOrphanedCount(std::size_t count) override { orphanedCounts.push_back(count); + events.push_back("orphanedCount " + std::to_string(count)); } void showProgress(SharedData::TrackerProgress const&) override @@ -218,34 +226,40 @@ namespace Test { conflicts.insert_or_assign(conflict.conflictId.value(), conflict); shownConflicts.push_back(conflict); + events.push_back("conflict " + conflict.conflictId.value()); } void removeConflict(Ids::ConflictId const& conflictId) override { conflicts.erase(conflictId.value()); removedConflicts.push_back(conflictId.value()); + events.push_back("removeConflict " + conflictId.value()); } void showFailure(SharedData::UploadFailure const& failure) override { failures.insert_or_assign(failure.failureId.value(), failure); + events.push_back("failure " + failure.failureId.value()); } void removeFailure(Ids::FailureId const& failureId) override { failures.erase(failureId.value()); removedFailures.push_back(failureId.value()); + events.push_back("removeFailure " + failureId.value()); } void showWatchProgress(SharedData::WatchProgress const& shown) override { watchProgress.push_back(shown); watchProgressShownAt.push_back(std::chrono::steady_clock::now()); + events.push_back("progress " + shown.watchId.value()); } void showOrphanedCopies(SharedData::OrphanedCopies const& copies) override { orphanedCopies.push_back(copies); + events.push_back("orphans " + std::to_string(copies.copies.size())); } std::map entries{}; @@ -264,6 +278,8 @@ namespace Test /** @brief When each of watchProgress was shown. */ std::vector watchProgressShownAt{}; std::vector orphanedCopies{}; + /** @brief Every call, in order: what it was and the id it was about. */ + std::vector events{}; }; /** @@ -715,6 +731,31 @@ namespace Test ); } + /** + * @brief Why the view shows the watch @p watchId as stopped, nothing when it shows it running or not at all. + */ + std::optional stopOf(Ids::WatchId const& watchId) const + { + const auto found = view_->entries.find(watchId.value()); + if (found == view_->entries.end()) + return std::nullopt; + return found->second.stop; + } + + std::optional stopReasonOf(Ids::WatchId const& watchId) const + { + const auto stop = stopOf(watchId); + return stop ? std::optional{stop->reason} : std::nullopt; + } + + static std::uint64_t secondsNow() + { + return static_cast( + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count() + ); + } + Ids::WatchId watchIdNamed(std::string const& name) const { for (auto const& entry : std::views::values(view_->entries)) @@ -1374,10 +1415,13 @@ namespace Test const auto onCopyProgress = lastUploadTo("/srv/notes.txt")->onUploadProgress; onFileProgress(fileProgress(file, 3u, 13u)); onCopyProgress(fileProgress(opened->localPath.native(), 4u, 12u)); - const auto shownOfProject = progressOf(projectId).size(); const auto shownOfCopy = progressOf(copyId).size(); tracker_->stop(projectId); + // The stopped watch shows its progress once more, with nothing on its way. + const auto shownOfProject = progressOf(projectId).size(); + EXPECT_EQ(progressOf(projectId).back().changesPending, 0u); + EXPECT_TRUE(progressOf(projectId).back().inFlight.empty()); ASSERT_TRUE(tracker_->discard(opened->instanceId, opened->localPath).has_value()); onFileProgress(fileProgress(file, 13u, 13u, /*transferred=*/true)); onCopyProgress(fileProgress(opened->localPath.native(), 12u, 12u, /*transferred=*/true)); @@ -1557,6 +1601,55 @@ namespace Test EXPECT_EQ(uploadsTo("/srv/project"), 2u); } + TEST_F(TrackerConflictsTest, StoppingAWatchRemovesItsConflictsAndFailuresBeforeItShowsTheStop) + { + ASSERT_NO_FATAL_FAILURE(raiseTwoConflicts()); + const auto readme = root_ / "local" / "project" / "readme.md"; + write(readme, "readme"); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/project/readme.md") == 1u; + } + )); + endAs( + *lastUploadTo("/srv/project/readme.md"), + {.reason = SharedData::OperationCompletionReason::Failed, + .failure = FileTracking::UploadFailureInfo{.reason = SharedData::UploadFailureReason::Unknown}} + ); + ASSERT_EQ(view_->failures.size(), 1u); + const auto watchId = onlyEntry().watchId; + const auto failureId = view_->failures.begin()->first; + const auto mainConflict = conflictIdAt(main_); + const auto utilConflict = conflictIdAt(util_); + view_->events.clear(); + + tracker_->stop(watchId); + + EXPECT_EQ( + view_->events, + (std::vector{ + "removeConflict " + mainConflict.value(), + "removeConflict " + utilConflict.value(), + "removeFailure " + failureId, + "progress " + watchId.value(), + "entry " + watchId.value() + " StoppedByUser", + }) + ); + EXPECT_TRUE(view_->conflicts.empty()); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(view_->watchProgress.back().changesPending, 0u); + EXPECT_EQ(view_->watchProgress.back().waitingForAnswer, 0u); + EXPECT_FALSE(onlyEntry().uploading); + const auto events = view_->events.size(); + tracker_->answerAllConflicts(watchId, SharedData::ConflictResolution::ReplaceOnServer); + tracker_->retryFailure(Ids::makeFailureId(failureId)); + runFor(std::chrono::milliseconds{500}); + EXPECT_EQ(view_->events.size(), events); + EXPECT_EQ(uploadsTo("/srv/project/readme.md"), 1u); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); + } + TEST_F(TrackerTest, StoppingADownloadedCopyWithAConflictFreesItsServerPath) { const auto opened = open("/srv/notes.txt", /*watch=*/true); @@ -1621,9 +1714,11 @@ namespace Test EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(view_->removedConflicts, (std::vector{mainConflict.value(), utilConflict.value()})); - // The view drops the progress with the entry. + // The stopped watch shows its progress once more, waiting for nothing. + ASSERT_EQ(view_->watchProgress.size(), shown + 1u); + EXPECT_EQ(countsOf(view_->watchProgress.back()), ProgressCounts{.synced = 4u}); runFor(std::chrono::milliseconds{300}); - EXPECT_EQ(view_->watchProgress.size(), shown); + EXPECT_EQ(view_->watchProgress.size(), shown + 1u); } TEST_F(TrackerConflictsTest, TheProgressCountsTheChangesWaitingForAnAnswerFromTheConflicts) @@ -2036,13 +2131,28 @@ namespace Test ASSERT_EQ(view_->ended.front().entries.size(), 1u); EXPECT_EQ(view_->ended.front().entries.front().watchId, watchId); EXPECT_EQ(view_->ended.front().entries.front().name, "notes.txt"); - EXPECT_TRUE(view_->entries.empty()); + // Listed as stopped until the user removes it. + auto const& entry = onlyEntry(); + ASSERT_TRUE(entry.stop.has_value()); + EXPECT_EQ(entry.stop->reason, SharedData::WatchStopReason::CopyGone); + EXPECT_GT(entry.stop->stoppedAt, 0u); + EXPECT_FALSE(entry.stop->otherWatchId.has_value()); + EXPECT_FALSE(entry.stop->movedTo.has_value()); + EXPECT_EQ(entry.instanceId, std::optional{opened->instanceId}); + ASSERT_TRUE(view_->ended.front().entries.front().stop.has_value()); + EXPECT_EQ(view_->ended.front().entries.front().stop->reason, SharedData::WatchStopReason::CopyGone); + EXPECT_EQ(view_->ended.front().entries.front().stop->stoppedAt, entry.stop->stoppedAt); + EXPECT_TRUE(view_->removed.empty()); EXPECT_TRUE(remote_->of("remove").empty()); EXPECT_EQ(instances_->findInstance(opened->instanceId.value()), nullptr); // Nothing was left in the instance, it went with its directory. EXPECT_FALSE(std::filesystem::exists(opened->localPath.native().parent_path())); EXPECT_EQ(instances_->countOrphaned(), 0u); + const auto shown = progressOf(watchId).size(); EXPECT_TRUE(open("/srv/notes.txt", /*watch=*/true).has_value()); + runFor(std::chrono::milliseconds{300}); + EXPECT_EQ(progressOf(watchId).size(), shown); + EXPECT_EQ(stopReasonOf(watchId), SharedData::WatchStopReason::CopyGone); } TEST_F(TrackerTest, ADownloadedCopyRenamedAwayEndsItsWatchAndStaysForTheCleanup) @@ -2063,7 +2173,7 @@ namespace Test runFor(std::chrono::milliseconds{1000}); EXPECT_EQ(view_->ended.front().reason, SharedData::WatchEndReason::CopyGone); - EXPECT_TRUE(view_->entries.empty()); + EXPECT_EQ(stopReasonOf(onlyEntry().watchId), SharedData::WatchStopReason::CopyGone); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 0u); EXPECT_TRUE(remote_->of("rename").empty()); EXPECT_TRUE(std::filesystem::exists(renamed)); @@ -2078,8 +2188,10 @@ namespace Test tracker_->stop(watchId); - EXPECT_TRUE(view_->entries.empty()); - EXPECT_EQ(view_->removed, std::vector{watchId.value()}); + EXPECT_EQ(stopReasonOf(watchId), SharedData::WatchStopReason::StoppedByUser); + EXPECT_EQ(onlyEntry().instanceId, std::optional{opened->instanceId}); + EXPECT_TRUE(view_->removed.empty()); + EXPECT_TRUE(view_->ended.empty()); EXPECT_EQ(instances_->findInstance(opened->instanceId.value()), nullptr); EXPECT_FALSE(view_->orphanedCounts.empty()); EXPECT_TRUE(open("/srv/notes.txt", /*watch=*/true).has_value()); @@ -2093,11 +2205,237 @@ namespace Test tracker_->stop(onlyEntry().watchId); - EXPECT_TRUE(view_->entries.empty()); + EXPECT_EQ(stopReasonOf(onlyEntry().watchId), SharedData::WatchStopReason::StoppedByUser); EXPECT_TRUE(std::filesystem::exists(root_ / "local" / "notes.txt")); EXPECT_TRUE(tracker_->checkUploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv")).has_value()); } + TEST_F(TrackerTest, AWatchStoppedByTheUserStaysListedWithItsLastSyncAndHearsNothingMore) + { + const auto notes = root_ / "local" / "notes.txt"; + ASSERT_TRUE(tracker_->uploadAndWatch(local(notes), remote("/srv/notes.txt")).has_value()); + const auto watchId = onlyEntry().watchId; + completeUploads(); + ASSERT_TRUE(runUntilProgress(watchId, {.synced = 1u})); + write(notes, "changed locally"); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/notes.txt") == 2u; + } + )); + const auto onProgress = lastUploadTo("/srv/notes.txt")->onUploadProgress; + const auto onEnded = lastUploadTo("/srv/notes.txt")->onUploadEnded; + const auto before = secondsNow(); + view_->events.clear(); + + tracker_->stop(watchId); + + EXPECT_EQ( + view_->events, + (std::vector{"progress " + watchId.value(), "entry " + watchId.value() + " StoppedByUser"}) + ); + auto const& entry = onlyEntry(); + ASSERT_TRUE(entry.stop.has_value()); + EXPECT_EQ(entry.stop->reason, SharedData::WatchStopReason::StoppedByUser); + EXPECT_GE(entry.stop->stoppedAt, before); + EXPECT_LE(entry.stop->stoppedAt, secondsNow()); + EXPECT_FALSE(entry.stop->otherWatchId.has_value()); + EXPECT_FALSE(entry.stop->otherWatchName.has_value()); + EXPECT_FALSE(entry.stop->movedTo.has_value()); + EXPECT_FALSE(entry.stop->wouldGoTo.has_value()); + EXPECT_FALSE(entry.uploading); + EXPECT_FALSE(entry.isDirectory); + EXPECT_EQ(entry.localPath.native(), notes); + EXPECT_EQ(entry.remotePath.value(), "/srv/notes.txt"); + const auto last = *lastProgressOf(watchId); + EXPECT_EQ(countsOf(last), ProgressCounts{.synced = 1u}); + EXPECT_EQ(last.bytesTotal, 0u); + EXPECT_TRUE(last.inFlight.empty()); + EXPECT_TRUE(view_->ended.empty()); + + // What was on its way ends unshown, and what changes later goes nowhere. + onProgress(fileProgress(notes, 15u, 15u, /*transferred=*/true)); + onEnded( + {.reason = SharedData::OperationCompletionReason::Failed, + .failure = FileTracking::UploadFailureInfo{.reason = SharedData::UploadFailureReason::Unknown}} + ); + write(notes, "changed again"); + runFor(std::chrono::milliseconds{1000}); + EXPECT_EQ(view_->events.size(), 2u); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); + tracker_->stop(watchId); + EXPECT_EQ(view_->events.size(), 2u); + } + + TEST_F(TrackerTest, AStoppedDirectoryWatchStaysADirectoryAfterItsItemWasDeleted) + { + const auto project = root_ / "local" / "project"; + ASSERT_TRUE(tracker_->uploadAndWatch(local(project), remote("/srv/project")).has_value()); + completeEverything(); + std::filesystem::remove_all(project); + + tracker_->stop(onlyEntry().watchId); + + ASSERT_TRUE(onlyEntry().stop.has_value()); + EXPECT_TRUE(onlyEntry().isDirectory); + } + + TEST_F(TrackerTest, AWatchedFileRenamedOntoTheServerPathOfAnotherWatchStopsAsOverlapping) + { + const auto first = root_ / "local" / "a" / "f1.txt"; + const auto second = root_ / "local" / "b" / "g.txt"; + const auto moved = root_ / "local" / "b" / "f1.txt"; + std::filesystem::create_directories(first.parent_path()); + std::filesystem::create_directories(second.parent_path()); + write(first, "first"); + write(second, "second"); + ASSERT_TRUE(tracker_->uploadAndWatch(local(first), remote("/srv/R5/f1.txt")).has_value()); + ASSERT_TRUE(tracker_->uploadAndWatch(local(second), remote("/srv/R5/g.txt")).has_value()); + completeEverything(); + const auto firstId = watchIdNamed("f1.txt"); + const auto secondId = watchIdNamed("g.txt"); + const auto serverBefore = remote_->server; + runFor(std::chrono::milliseconds{300}); + view_->events.clear(); + + std::filesystem::rename(second, moved); + + ASSERT_TRUE(runUntil( + [this, &secondId]() + { + return stopOf(secondId).has_value(); + }, + std::chrono::seconds{10} + )); + const auto stop = *stopOf(secondId); + EXPECT_EQ(stop.reason, SharedData::WatchStopReason::Overlap); + EXPECT_GT(stop.stoppedAt, 0u); + EXPECT_EQ(stop.otherWatchId, std::optional{firstId}); + EXPECT_EQ(stop.otherWatchName, std::optional{"f1.txt"}); + EXPECT_EQ(stop.movedTo, std::optional{local(moved)}); + EXPECT_EQ(stop.wouldGoTo, std::optional{remote("/srv/R5/f1.txt")}); + auto const& stoppedEntry = view_->entries.at(secondId.value()); + EXPECT_EQ(stoppedEntry.remotePath.value(), "/srv/R5/g.txt"); + EXPECT_EQ(stoppedEntry.localPath.native(), second); + EXPECT_EQ(stoppedEntry.name, "g.txt"); + ASSERT_EQ(view_->ended.size(), 1u); + EXPECT_EQ(view_->ended.front().reason, SharedData::WatchEndReason::Overlap); + ASSERT_EQ(view_->ended.front().entries.size(), 1u); + EXPECT_EQ(view_->ended.front().entries.front().watchId, secondId); + ASSERT_TRUE(view_->ended.front().entries.front().stop.has_value()); + EXPECT_EQ(view_->ended.front().entries.front().stop->otherWatchId, std::optional{firstId}); + EXPECT_EQ( + view_->events, + (std::vector{ + "progress " + secondId.value(), "entry " + secondId.value() + " Overlap", "ended Overlap" + }) + ); + EXPECT_FALSE(stopOf(firstId).has_value()); + + // Both server copies stay as they are, the moved file's edits go nowhere. + write(moved, "edited after the move"); + runFor(std::chrono::milliseconds{1000}); + EXPECT_TRUE(remote_->of("rename").empty()); + EXPECT_EQ(uploadsTo("/srv/R5/f1.txt"), 1u); + EXPECT_EQ(remote_->server, serverBefore); + EXPECT_EQ(view_->events.size(), 3u); + } + + TEST_F(TrackerTest, ANewWatchOfAStoppedItemOrServerPathWorksWhileTheStoppedOneIsListed) + { + const auto notes = root_ / "local" / "notes.txt"; + ASSERT_TRUE(tracker_->uploadAndWatch(local(notes), remote("/srv/notes.txt")).has_value()); + const auto opened = open("/srv/copy.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + completeEverything(); + const auto notesId = watchIdNamed("notes.txt"); + const auto copyId = watchIdNamed("copy.txt"); + tracker_->stop(notesId); + tracker_->stop(copyId); + + ASSERT_TRUE(tracker_->checkUploadAndWatch(local(notes), remote("/srv")).has_value()); + ASSERT_TRUE(tracker_->uploadAndWatch(local(notes), remote("/srv/notes.txt")).has_value()); + const auto reopened = open("/srv/copy.txt", /*watch=*/true); + ASSERT_TRUE(reopened.has_value()); + + ASSERT_EQ(view_->entries.size(), 4u); + EXPECT_TRUE(stopOf(notesId).has_value()); + EXPECT_TRUE(stopOf(copyId).has_value()); + completeEverything(); + write(notes, "changed locally"); + write(reopened->localPath.native(), "changed copy"); + EXPECT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/notes.txt") == 3u && uploadsTo("/srv/copy.txt") == 1u; + } + )); + } + + TEST_F(TrackerTest, RemoveStoppedTakesOneStoppedWatchOffTheViewAndRemoveAllTheRest) + { + ASSERT_TRUE( + tracker_->uploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv/notes.txt")).has_value() + ); + ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + const auto opened = open("/srv/copy.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + completeEverything(); + const auto notesId = watchIdNamed("notes.txt"); + const auto projectId = watchIdNamed("project"); + const auto copyId = watchIdNamed("copy.txt"); + tracker_->stop(notesId); + tracker_->stop(copyId); + + tracker_->removeStopped(notesId); + EXPECT_EQ(view_->removed, std::vector{notesId.value()}); + // Neither a running watch nor one removed already. + tracker_->removeStopped(projectId); + tracker_->removeStopped(notesId); + EXPECT_EQ(view_->removed, std::vector{notesId.value()}); + EXPECT_TRUE(view_->entries.contains(projectId.value())); + EXPECT_FALSE(stopOf(projectId).has_value()); + + tracker_->removeAllStopped(); + EXPECT_EQ(view_->removed, (std::vector{notesId.value(), copyId.value()})); + ASSERT_EQ(view_->entries.size(), 1u); + EXPECT_EQ(onlyEntry().watchId, projectId); + tracker_->removeAllStopped(); + EXPECT_EQ(view_->removed.size(), 2u); + + write(root_ / "local" / "project" / "src" / "main.cpp", "changed locally"); + EXPECT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/project/src/main.cpp") == 1u; + } + )); + } + + TEST_F(TrackerTest, StoppingAllSendsNothingAndKeepsNoStoppedWatch) + { + ASSERT_TRUE( + tracker_->uploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv/notes.txt")).has_value() + ); + ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + completeEverything(); + const auto notesId = watchIdNamed("notes.txt"); + tracker_->stop(notesId); + runFor(std::chrono::milliseconds{300}); + view_->events.clear(); + + tracker_->stopAll(); + write(root_ / "local" / "project" / "src" / "main.cpp", "changed locally"); + runFor(std::chrono::milliseconds{1000}); + + EXPECT_TRUE(view_->events.empty()); + tracker_->removeStopped(notesId); + tracker_->removeAllStopped(); + EXPECT_TRUE(view_->removed.empty()); + } + TEST_F(TrackerTest, DiscardingADownloadEndsItsWatchAndItsInstance) { const auto opened = open("/srv/notes.txt", /*watch=*/true); @@ -2155,19 +2493,35 @@ namespace Test ASSERT_TRUE(runUntilProgress(watchIdNamed("notes.txt"), {.pending = 1u})); const auto shown = view_->watchProgress.size(); + const auto copyId = watchIdNamed("copy.txt"); + const auto notesId = watchIdNamed("notes.txt"); + view_->events.clear(); + tracker_->connectionLost(); write(root_ / "local" / "notes.txt", "changed locally"); write(copy->localPath.native(), "changed copy"); runFor(std::chrono::milliseconds{1000}); - EXPECT_TRUE(view_->entries.empty()); - EXPECT_EQ(view_->removed.size(), 2u); + ASSERT_EQ(view_->entries.size(), 2u); + for (auto const& watchId : {copyId, notesId}) + { + const auto stop = stopOf(watchId); + ASSERT_TRUE(stop.has_value()); + EXPECT_EQ(stop->reason, SharedData::WatchStopReason::ConnectionLost); + EXPECT_GT(stop->stoppedAt, 0u); + EXPECT_FALSE(view_->entries.at(watchId.value()).uploading); + } + EXPECT_TRUE(view_->removed.empty()); ASSERT_EQ(view_->ended.size(), 1u); EXPECT_EQ(view_->ended.front().reason, SharedData::WatchEndReason::ConnectionLost); const auto names = view_->ended.front().entries | std::views::transform(&SharedData::TrackedEntry::name) | std::ranges::to(); EXPECT_EQ(names, (std::set{"copy.txt", "notes.txt"})); - EXPECT_EQ(view_->watchProgress.size(), shown); + // Each shows its progress once more, nothing on its way, then the toast follows. + ASSERT_EQ(view_->watchProgress.size(), shown + 2u); + EXPECT_EQ(countsOf(*lastProgressOf(notesId)), ProgressCounts{}); + ASSERT_EQ(view_->events.size(), 6u); + EXPECT_EQ(view_->events[4], "ended ConnectionLost"); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); EXPECT_EQ(uploadsTo("/srv/copy.txt"), 0u); EXPECT_EQ(instances_->findInstance(copy->instanceId.value()), nullptr); From 6965d796b22a1e8a8aa78bab51d4fca3f576bf01 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:45:47 +0200 Subject: [PATCH 083/114] Listed the orphaned copies per instance, leaving out those of stopped watches - open records where a copy came from; the list goes to the view after every stop, removeStopped(All), a lost connection and the three orphan RPCs, instead of the orphan count - removeOrphanedCopy refuses a stopped watch's copy besides what the instance manager refuses, removeAllOrphanedCopies removes what the list shows - The fake view fails on showOrphanedCount; the stop and lost connection tests check the list instead of the count --- .../include/backend/file_tracking/tracker.hpp | 6 +- .../source/backend/file_tracking/tracker.cpp | 86 +++++++- backend/test/backend/test_tracker.hpp | 195 +++++++++++++++++- 3 files changed, 262 insertions(+), 25 deletions(-) diff --git a/backend/include/backend/file_tracking/tracker.hpp b/backend/include/backend/file_tracking/tracker.hpp index 517e8304..5c524ec6 100644 --- a/backend/include/backend/file_tracking/tracker.hpp +++ b/backend/include/backend/file_tracking/tracker.hpp @@ -214,13 +214,13 @@ namespace FileTracking void listOrphanedCopies(); /** - * @brief Removes the orphaned copy @p instanceId with its directory. - * @return An error when it is no orphaned copy or cannot be removed. + * @brief Removes the orphaned copy @p instanceId with its directory, the view shows the orphaned copies again. + * @return An error when it is no orphaned copy, is the copy of a stopped watch or cannot be removed. */ std::expected removeOrphanedCopy(Ids::InstanceId const& instanceId); /** - * @brief Removes every orphaned copy, see removeOrphanedCopy(). + * @brief Removes every orphaned copy listOrphanedCopies() shows, see removeOrphanedCopy(). */ void removeAllOrphanedCopies(); diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index c10eb49a..aa330706 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -13,10 +13,12 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -865,6 +867,9 @@ namespace FileTracking OpenFailure{Utility::localizedMessage("backend.fileTracking.instanceCreationFailed")} }); const auto instanceId = Ids::makeInstanceId(instance->instanceId()); + // Only the orphan list shows it, the copy opens without it. + if (const auto recorded = instance->recordOrigin(remotePath); !recorded) + Log::warn("FileTracking: {}", recorded.error()); downloading.insert(instanceId); // Canonical like the paths of the changes, which the copy's path is compared with. std::error_code canonicalError; @@ -1045,9 +1050,8 @@ namespace FileTracking void stop(Ids::WatchId const& watchId) { - const auto retired = retire(watchId, watchStopOf(SharedData::WatchStopReason::StoppedByUser)); - if (retired && retired->instanceId) - view->showOrphanedCount(instances->countOrphaned()); + if (retire(watchId, watchStopOf(SharedData::WatchStopReason::StoppedByUser))) + showOrphanedCopies(); } /** @@ -1233,7 +1237,7 @@ namespace FileTracking leaveOrder(ticket); if (!ended.entries.empty()) view->showEnded(ended); - view->showOrphanedCount(instances->countOrphaned()); + showOrphanedCopies(); } /** @@ -1622,7 +1626,7 @@ namespace FileTracking if (!retired) return; view->showEnded({.reason = SharedData::WatchEndReason::CopyGone, .entries = {*std::move(retired)}}); - view->showOrphanedCount(instances->countOrphaned()); + showOrphanedCopies(); } /** @@ -1692,8 +1696,10 @@ namespace FileTracking stop.movedTo = movedTo; stop.wouldGoTo = wouldGoTo; auto retired = retire(watchId, std::move(stop)); - if (retired) - view->showEnded({.reason = SharedData::WatchEndReason::Overlap, .entries = {*std::move(retired)}}); + if (!retired) + return; + view->showEnded({.reason = SharedData::WatchEndReason::Overlap, .entries = {*std::move(retired)}}); + showOrphanedCopies(); } void onMoved(Entry& entry, Paths::LocalPath const& oldPath, LocalChange const& change) @@ -2401,6 +2407,8 @@ namespace FileTracking } Log::info("FileTracking: removing the stopped watch {}", watchId.value()); view->removeEntry(watchId); + // A stopped copy is an orphan from now on. + showOrphanedCopies(); } void removeAllStopped() @@ -2409,22 +2417,78 @@ namespace FileTracking Log::info("FileTracking: removing {} stopped watch(es)", removed.size()); for (auto const& watchId : std::views::keys(removed)) view->removeEntry(watchId); + showOrphanedCopies(); } void listOrphanedCopies() { - Log::warn("FileTracking: listing the orphaned copies is not implemented"); + showOrphanedCopies(); } std::expected removeOrphanedCopy(Ids::InstanceId const& instanceId) { - Log::warn("FileTracking: removing the orphaned copy {} is not implemented", instanceId.value()); - return std::unexpected{std::string{"Removing an orphaned copy is not implemented"}}; + const auto removed = refuseStoppedCopy(instanceId).and_then( + [this, &instanceId]() + { + return instances->removeOrphanedInstance(instanceId); + } + ); + if (!removed) + Log::warn("FileTracking: {}", removed.error()); + showOrphanedCopies(); + return removed; } void removeAllOrphanedCopies() { - Log::warn("FileTracking: removing the orphaned copies is not implemented"); + const auto listed = orphanedCopies(); + Log::info("FileTracking: removing {} orphaned copies", listed.copies.size()); + for (auto const& copy : listed.copies) + if (const auto removed = instances->removeOrphanedInstance(copy.instanceId); !removed) + Log::warn("FileTracking: {}", removed.error()); + showOrphanedCopies(); + } + + /** + * @brief The orphaned copies as the view lists them: those of stopped watches are left out, the view shows + * them as stopped until they are removed. + */ + SharedData::OrphanedCopies orphanedCopies() const + { + auto copies = instances->listOrphaned(); + std::erase_if( + copies, + [this](SharedData::OrphanedCopy const& copy) + { + return isStoppedCopy(copy.instanceId); + } + ); + return {.copies = std::move(copies)}; + } + + void showOrphanedCopies() + { + view->showOrphanedCopies(orphanedCopies()); + } + + bool isStoppedCopy(Ids::InstanceId const& instanceId) const + { + return std::ranges::any_of( + std::views::values(stopped), + [&instanceId](SharedData::TrackedEntry const& entry) + { + return entry.instanceId == instanceId; + } + ); + } + + std::expected refuseStoppedCopy(Ids::InstanceId const& instanceId) const + { + if (!isStoppedCopy(instanceId)) + return {}; + return std::unexpected{fmt::format( + "Cannot remove the orphaned instance '{}': it is the copy of a stopped watch", instanceId.value() + )}; } /** diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index cd44ff55..650dcfe7 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -213,8 +213,7 @@ namespace Test void showOrphanedCount(std::size_t count) override { - orphanedCounts.push_back(count); - events.push_back("orphanedCount " + std::to_string(count)); + ADD_FAILURE() << "the tracker lists the orphaned copies, it counted " << count; } void showProgress(SharedData::TrackerProgress const&) override @@ -265,7 +264,6 @@ namespace Test std::map entries{}; std::vector removed{}; std::vector ended{}; - std::vector orphanedCounts{}; /** @brief The conflicts shown and not removed, by id. */ std::map conflicts{}; /** @brief Every conflict shown, in order. */ @@ -1634,6 +1632,7 @@ namespace Test "removeFailure " + failureId, "progress " + watchId.value(), "entry " + watchId.value() + " StoppedByUser", + "orphans 0", }) ); EXPECT_TRUE(view_->conflicts.empty()); @@ -2169,6 +2168,14 @@ namespace Test }, FileTracking::copyReturnTime + std::chrono::seconds{2} )); + const auto watchId = onlyEntry().watchId; + ASSERT_GE(view_->events.size(), 4u); + EXPECT_EQ( + std::vector(view_->events.end() - 4, view_->events.end()), + (std::vector{ + "progress " + watchId.value(), "entry " + watchId.value() + " CopyGone", "ended CopyGone", "orphans 0" + }) + ); write(renamed, "edited after the rename"); runFor(std::chrono::milliseconds{1000}); @@ -2178,6 +2185,15 @@ namespace Test EXPECT_TRUE(remote_->of("rename").empty()); EXPECT_TRUE(std::filesystem::exists(renamed)); EXPECT_EQ(instances_->countOrphaned(), 1u); + EXPECT_TRUE(view_->orphanedCopies.back().copies.empty()); + + tracker_->removeStopped(watchId); + + ASSERT_EQ(view_->orphanedCopies.back().copies.size(), 1u); + auto const& orphan = view_->orphanedCopies.back().copies.front(); + EXPECT_EQ(orphan.instanceId, opened->instanceId); + EXPECT_EQ(orphan.name, "renamed.txt"); + EXPECT_EQ(orphan.remotePath, std::optional{remote("/srv/notes.txt")}); } TEST_F(TrackerTest, StoppingADownloadedCopyEndsItsInstance) @@ -2193,7 +2209,10 @@ namespace Test EXPECT_TRUE(view_->removed.empty()); EXPECT_TRUE(view_->ended.empty()); EXPECT_EQ(instances_->findInstance(opened->instanceId.value()), nullptr); - EXPECT_FALSE(view_->orphanedCounts.empty()); + // An orphan on disk, which the list leaves out while its watch is listed as stopped. + EXPECT_EQ(instances_->countOrphaned(), 1u); + ASSERT_FALSE(view_->orphanedCopies.empty()); + EXPECT_TRUE(view_->orphanedCopies.back().copies.empty()); EXPECT_TRUE(open("/srv/notes.txt", /*watch=*/true).has_value()); } @@ -2233,7 +2252,9 @@ namespace Test EXPECT_EQ( view_->events, - (std::vector{"progress " + watchId.value(), "entry " + watchId.value() + " StoppedByUser"}) + (std::vector{ + "progress " + watchId.value(), "entry " + watchId.value() + " StoppedByUser", "orphans 0" + }) ); auto const& entry = onlyEntry(); ASSERT_TRUE(entry.stop.has_value()); @@ -2262,11 +2283,11 @@ namespace Test ); write(notes, "changed again"); runFor(std::chrono::milliseconds{1000}); - EXPECT_EQ(view_->events.size(), 2u); + EXPECT_EQ(view_->events.size(), 3u); EXPECT_TRUE(view_->failures.empty()); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); tracker_->stop(watchId); - EXPECT_EQ(view_->events.size(), 2u); + EXPECT_EQ(view_->events.size(), 3u); } TEST_F(TrackerTest, AStoppedDirectoryWatchStaysADirectoryAfterItsItemWasDeleted) @@ -2329,7 +2350,10 @@ namespace Test EXPECT_EQ( view_->events, (std::vector{ - "progress " + secondId.value(), "entry " + secondId.value() + " Overlap", "ended Overlap" + "progress " + secondId.value(), + "entry " + secondId.value() + " Overlap", + "ended Overlap", + "orphans 0", }) ); EXPECT_FALSE(stopOf(firstId).has_value()); @@ -2340,7 +2364,7 @@ namespace Test EXPECT_TRUE(remote_->of("rename").empty()); EXPECT_EQ(uploadsTo("/srv/R5/f1.txt"), 1u); EXPECT_EQ(remote_->server, serverBefore); - EXPECT_EQ(view_->events.size(), 3u); + EXPECT_EQ(view_->events.size(), 4u); } TEST_F(TrackerTest, ANewWatchOfAStoppedItemOrServerPathWorksWhileTheStoppedOneIsListed) @@ -2436,6 +2460,153 @@ namespace Test EXPECT_TRUE(view_->removed.empty()); } + TEST_F(TrackerTest, OpenRecordsWhereTheCopyCameFromForTheOrphanList) + { + const auto opened = open("/srv/docs/notes.txt", /*watch=*/false); + ASSERT_TRUE(opened.has_value()); + instances_->destroyInstance(opened->instanceId.value()); + + tracker_->listOrphanedCopies(); + + EXPECT_EQ(view_->events, std::vector{"orphans 1"}); + ASSERT_EQ(view_->orphanedCopies.size(), 1u); + ASSERT_EQ(view_->orphanedCopies.back().copies.size(), 1u); + auto const& copy = view_->orphanedCopies.back().copies.front(); + EXPECT_EQ(copy.instanceId, opened->instanceId); + EXPECT_EQ(copy.name, "notes.txt"); + EXPECT_EQ(copy.remotePath, std::optional{remote("/srv/docs/notes.txt")}); + EXPECT_EQ(copy.copyPath, std::optional{opened->localPath}); + EXPECT_EQ(copy.sizeBytes, std::string{"server content"}.size()); + } + + TEST_F(TrackerTest, AStoppedCopyIsListedOnlyAsStoppedUntilItsStoppedWatchIsRemoved) + { + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; + + tracker_->stop(watchId); + + EXPECT_EQ(view_->events.back(), "orphans 0"); + EXPECT_EQ(instances_->countOrphaned(), 1u); + tracker_->listOrphanedCopies(); + ASSERT_EQ(view_->orphanedCopies.size(), 2u); + EXPECT_TRUE(view_->orphanedCopies.back().copies.empty()); + view_->events.clear(); + + tracker_->removeStopped(watchId); + + EXPECT_EQ(view_->events, (std::vector{"removeEntry " + watchId.value(), "orphans 1"})); + ASSERT_EQ(view_->orphanedCopies.back().copies.size(), 1u); + auto const& copy = view_->orphanedCopies.back().copies.front(); + EXPECT_EQ(copy.instanceId, opened->instanceId); + EXPECT_EQ(copy.name, "notes.txt"); + EXPECT_EQ(copy.remotePath, std::optional{remote("/srv/notes.txt")}); + EXPECT_TRUE(copy.downloadedAt.has_value()); + EXPECT_TRUE(copy.endedAt.has_value()); + } + + TEST_F(TrackerTest, RemovingAllStoppedWatchesListsTheirCopiesAsOrphaned) + { + ASSERT_TRUE( + tracker_->uploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv/notes.txt")).has_value() + ); + const auto first = open("/srv/first.txt", /*watch=*/true); + const auto second = open("/srv/second.txt", /*watch=*/true); + ASSERT_TRUE(first.has_value()); + ASSERT_TRUE(second.has_value()); + const auto watchIds = std::views::keys(view_->entries) | std::ranges::to(); + for (auto const& watchId : watchIds) + tracker_->stop(Ids::makeWatchId(watchId)); + EXPECT_TRUE(view_->orphanedCopies.back().copies.empty()); + view_->events.clear(); + + tracker_->removeAllStopped(); + + ASSERT_EQ(view_->events.size(), 4u); + EXPECT_EQ(view_->events.back(), "orphans 2"); + EXPECT_TRUE(view_->entries.empty()); + const auto listed = view_->orphanedCopies.back().copies | + std::views::transform(&SharedData::OrphanedCopy::instanceId) | std::ranges::to(); + EXPECT_TRUE(std::ranges::contains(listed, first->instanceId)); + EXPECT_TRUE(std::ranges::contains(listed, second->instanceId)); + } + + TEST_F(TrackerTest, RemovingAnOrphanedCopyRefusesTheCopyOfAStoppedWatchAndALiveOne) + { + const auto stoppedCopy = open("/srv/stopped.txt", /*watch=*/true); + const auto liveCopy = open("/srv/live.txt", /*watch=*/true); + const auto openedCopy = open("/srv/opened.txt", /*watch=*/false); + const auto orphan = open("/srv/orphan.txt", /*watch=*/true); + ASSERT_TRUE(stoppedCopy.has_value()); + ASSERT_TRUE(liveCopy.has_value()); + ASSERT_TRUE(openedCopy.has_value()); + ASSERT_TRUE(orphan.has_value()); + const auto orphanId = watchIdNamed("orphan.txt"); + tracker_->stop(watchIdNamed("stopped.txt")); + tracker_->stop(orphanId); + tracker_->removeStopped(orphanId); + ASSERT_EQ(view_->orphanedCopies.back().copies.size(), 1u); + const auto pushes = view_->orphanedCopies.size(); + + const auto ofStoppedWatch = tracker_->removeOrphanedCopy(stoppedCopy->instanceId); + ASSERT_FALSE(ofStoppedWatch.has_value()); + EXPECT_NE(ofStoppedWatch.error().find("stopped watch"), std::string::npos) << ofStoppedWatch.error(); + for (auto const& refused : + {liveCopy->instanceId, openedCopy->instanceId, Ids::makeInstanceId("../x"), Ids::generateInstanceId()}) + EXPECT_FALSE(tracker_->removeOrphanedCopy(refused).has_value()) << refused.value(); + + // Each answer shows the list again, which did not change. + EXPECT_EQ(view_->orphanedCopies.size(), pushes + 5u); + EXPECT_EQ(view_->orphanedCopies.back().copies.size(), 1u); + EXPECT_TRUE(std::filesystem::exists(stoppedCopy->localPath.native())); + EXPECT_TRUE(std::filesystem::exists(liveCopy->localPath.native())); + EXPECT_TRUE(std::filesystem::exists(openedCopy->localPath.native())); + + const auto removed = tracker_->removeOrphanedCopy(orphan->instanceId); + + ASSERT_TRUE(removed.has_value()) << removed.error(); + EXPECT_FALSE(std::filesystem::exists(orphan->localPath.native().parent_path())); + EXPECT_EQ(view_->orphanedCopies.size(), pushes + 6u); + EXPECT_TRUE(view_->orphanedCopies.back().copies.empty()); + EXPECT_EQ(instances_->countOrphaned(), 1u); + } + + TEST_F(TrackerTest, RemovingAllOrphanedCopiesRemovesWhatTheListShowsAndKeepsStoppedCopies) + { + const auto first = open("/srv/first.txt", /*watch=*/true); + const auto second = open("/srv/second.txt", /*watch=*/true); + const auto stoppedCopy = open("/srv/stopped.txt", /*watch=*/true); + const auto liveCopy = open("/srv/live.txt", /*watch=*/true); + ASSERT_TRUE(first.has_value()); + ASSERT_TRUE(second.has_value()); + ASSERT_TRUE(stoppedCopy.has_value()); + ASSERT_TRUE(liveCopy.has_value()); + const auto stoppedId = watchIdNamed("stopped.txt"); + for (auto const& name : {"first.txt", "second.txt"}) + { + const auto watchId = watchIdNamed(name); + tracker_->stop(watchId); + tracker_->removeStopped(watchId); + } + tracker_->stop(stoppedId); + ASSERT_EQ(view_->orphanedCopies.back().copies.size(), 2u); + view_->events.clear(); + + tracker_->removeAllOrphanedCopies(); + + EXPECT_EQ(view_->events, std::vector{"orphans 0"}); + EXPECT_FALSE(std::filesystem::exists(first->localPath.native().parent_path())); + EXPECT_FALSE(std::filesystem::exists(second->localPath.native().parent_path())); + EXPECT_TRUE(std::filesystem::exists(stoppedCopy->localPath.native())); + EXPECT_TRUE(std::filesystem::exists(liveCopy->localPath.native())); + EXPECT_EQ(instances_->countOrphaned(), 1u); + + tracker_->removeStopped(stoppedId); + ASSERT_EQ(view_->orphanedCopies.back().copies.size(), 1u); + EXPECT_EQ(view_->orphanedCopies.back().copies.front().instanceId, stoppedCopy->instanceId); + } + TEST_F(TrackerTest, DiscardingADownloadEndsItsWatchAndItsInstance) { const auto opened = open("/srv/notes.txt", /*watch=*/true); @@ -2522,11 +2693,13 @@ namespace Test EXPECT_EQ(countsOf(*lastProgressOf(notesId)), ProgressCounts{}); ASSERT_EQ(view_->events.size(), 6u); EXPECT_EQ(view_->events[4], "ended ConnectionLost"); + EXPECT_EQ(view_->events[5], "orphans 0"); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); EXPECT_EQ(uploadsTo("/srv/copy.txt"), 0u); EXPECT_EQ(instances_->findInstance(copy->instanceId.value()), nullptr); - ASSERT_FALSE(view_->orphanedCounts.empty()); - EXPECT_EQ(view_->orphanedCounts.back(), 1u); + EXPECT_EQ(instances_->countOrphaned(), 1u); + ASSERT_EQ(view_->orphanedCopies.size(), 1u); + EXPECT_TRUE(view_->orphanedCopies.back().copies.empty()); EXPECT_TRUE(tracker_->checkUploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv")).has_value()); } From f26dec7b5faa7ed27fa8062b69b7ab546800798b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:52:05 +0200 Subject: [PATCH 084/114] Showed the waiting-for-answer summary while every pending change waits in a conflict --- .../session_components/file_tracking/sync_progress_block.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp index 541e30a1..26f3d1e7 100644 --- a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -86,7 +86,10 @@ namespace FileTracking { if (entry.stop) return false; - return entry.uploading || !progress.inFlight.empty() || progress.changesPending > progress.waitingForAnswer; + if (!progress.inFlight.empty() || progress.changesPending > progress.waitingForAnswer) + return true; + // Uploads that wait for an answer keep the entry uploading; before the first progress only the flag tells. + return entry.uploading && progress.waitingForAnswer == 0u; } template From facadd956ae93bf914614812d95b4228985d50db Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:52:05 +0200 Subject: [PATCH 085/114] Wrapped the conflict and failure lines instead of cutting them off --- static/styles/file_tracking_tabs.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css index 38baed4b..cf9d3516 100644 --- a/static/styles/file_tracking_tabs.css +++ b/static/styles/file_tracking_tabs.css @@ -138,8 +138,7 @@ min-width: 0; } -.file-tracking-item-row-file, -.file-tracking-item-row-detail { +.file-tracking-item-row-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -148,6 +147,7 @@ .file-tracking-item-row-detail { color: var(--color-secondary); font-size: 0.75rem; + overflow-wrap: anywhere; } .file-tracking-item-row-actions { From 3cc4cce4c857e161c59d13d918e38afc7611b6e9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:52:05 +0200 Subject: [PATCH 086/114] Showed stopped watches with the inactive icon, not a checkbox-like square --- .../session_components/file_tracking/status_glyph.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp index 91fdf405..4f79ca26 100644 --- a/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp +++ b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp @@ -2,8 +2,8 @@ #include #include -#include #include +#include #include #include @@ -26,7 +26,7 @@ namespace FileTracking case WatchStatus::Failed: return Ui5Icons::warning(); case WatchStatus::Stopped: - return Ui5Icons::border(); + return Ui5Icons::status_inactive(); case WatchStatus::Watching: case WatchStatus::Syncing: break; From e73dcb288e667bf57af4240e32ef2fb95e7c7772 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:51:18 +0200 Subject: [PATCH 087/114] Removed ask, showProgress and showOrphanedCount from the tracker view - Dropped the onQuestion, onProgress and onOrphanedCount events and SharedData::TrackerProgress --- .../file_tracking/frontend_tracker_view.hpp | 6 +---- .../backend/file_tracking/tracker_view.hpp | 19 -------------- .../file_tracking/frontend_tracker_view.cpp | 15 ----------- backend/test/backend/test_tracker.hpp | 15 ----------- .../file_tracking/tracker_progress.hpp | 26 ------------------- 5 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 shared_data/include/shared_data/file_tracking/tracker_progress.hpp diff --git a/backend/include/backend/file_tracking/frontend_tracker_view.hpp b/backend/include/backend/file_tracking/frontend_tracker_view.hpp index ad2f24a9..06e2d578 100644 --- a/backend/include/backend/file_tracking/frontend_tracker_view.hpp +++ b/backend/include/backend/file_tracking/frontend_tracker_view.hpp @@ -16,8 +16,7 @@ namespace FileTracking /** * @brief Shows a Tracker in the file tracking panel of its session, which listens to * @c "FileTracker::::": onEntry (SharedData::TrackedEntry), onEntryRemoved (watch id), - * onEntriesEnded (SharedData::EndedWatches), onQuestion (SharedData::ServerChangedQuestion), - * onOrphanedCount (count), onProgress (SharedData::TrackerProgress), onConflict (SharedData::WatchConflict), + * onEntriesEnded (SharedData::EndedWatches), onConflict (SharedData::WatchConflict), * onConflictRemoved (conflict id), onFailure (SharedData::UploadFailure), onFailureRemoved (failure id), * onWatchProgress (SharedData::WatchProgress) and onOrphanedCopies (SharedData::OrphanedCopies). */ @@ -29,9 +28,6 @@ namespace FileTracking void showEntry(SharedData::TrackedEntry const& entry) override; void removeEntry(Ids::WatchId const& watchId) override; void showEnded(SharedData::EndedWatches const& ended) override; - void ask(SharedData::ServerChangedQuestion const& question) override; - void showOrphanedCount(std::size_t count) override; - void showProgress(SharedData::TrackerProgress const& progress) override; void showConflict(SharedData::WatchConflict const& conflict) override; void removeConflict(Ids::ConflictId const& conflictId) override; void showFailure(SharedData::UploadFailure const& failure) override; diff --git a/backend/include/backend/file_tracking/tracker_view.hpp b/backend/include/backend/file_tracking/tracker_view.hpp index b8e7b506..4e3575f4 100644 --- a/backend/include/backend/file_tracking/tracker_view.hpp +++ b/backend/include/backend/file_tracking/tracker_view.hpp @@ -3,15 +3,11 @@ #include #include #include -#include #include -#include #include #include #include -#include - namespace FileTracking { /** @@ -41,21 +37,6 @@ namespace FileTracking */ virtual void showEnded(SharedData::EndedWatches const& ended) = 0; - /** - * @brief Asks @p question, the tracker waits for the answer before it asks the next one. - */ - virtual void ask(SharedData::ServerChangedQuestion const& question) = 0; - - /** - * @brief Shows how many instance directories no live process holds any more. - */ - virtual void showOrphanedCount(std::size_t count) = 0; - - /** - * @brief Shows how far the tracker is with the changes on their way, at most a few times a second. - */ - virtual void showProgress(SharedData::TrackerProgress const& progress) = 0; - /** * @brief Shows @p conflict, a local change that waits for the user's answer. */ diff --git a/backend/source/backend/file_tracking/frontend_tracker_view.cpp b/backend/source/backend/file_tracking/frontend_tracker_view.cpp index 68a1ab44..5cb39b95 100644 --- a/backend/source/backend/file_tracking/frontend_tracker_view.cpp +++ b/backend/source/backend/file_tracking/frontend_tracker_view.cpp @@ -28,21 +28,6 @@ namespace FileTracking RpcHelper::callRemoteSafely(*hub_, eventName("onEntriesEnded"), ended); } - void FrontendTrackerView::ask(SharedData::ServerChangedQuestion const& question) - { - RpcHelper::callRemoteSafely(*hub_, eventName("onQuestion"), question); - } - - void FrontendTrackerView::showOrphanedCount(std::size_t count) - { - RpcHelper::callRemoteSafely(*hub_, eventName("onOrphanedCount"), count); - } - - void FrontendTrackerView::showProgress(SharedData::TrackerProgress const& progress) - { - RpcHelper::callRemoteSafely(*hub_, eventName("onProgress"), progress); - } - void FrontendTrackerView::showConflict(SharedData::WatchConflict const& conflict) { RpcHelper::callRemoteSafely(*hub_, eventName("onConflict"), conflict); diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 650dcfe7..105ce1d0 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -206,21 +206,6 @@ namespace Test events.push_back("ended " + Utility::enumToString(shown.reason)); } - void ask(SharedData::ServerChangedQuestion const& question) override - { - ADD_FAILURE() << "the tracker lists conflicts, it asked about " << question.remotePath.value(); - } - - void showOrphanedCount(std::size_t count) override - { - ADD_FAILURE() << "the tracker lists the orphaned copies, it counted " << count; - } - - void showProgress(SharedData::TrackerProgress const&) override - { - ADD_FAILURE() << "the tracker shows the progress per watch"; - } - void showConflict(SharedData::WatchConflict const& conflict) override { conflicts.insert_or_assign(conflict.conflictId.value(), conflict); diff --git a/shared_data/include/shared_data/file_tracking/tracker_progress.hpp b/shared_data/include/shared_data/file_tracking/tracker_progress.hpp deleted file mode 100644 index d7b8f2c8..00000000 --- a/shared_data/include/shared_data/file_tracking/tracker_progress.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace SharedData -{ - /** - * @brief How far the tracker of a session is with mirroring the changes of its watches, apart from the operation - * queue: the panel shows it while something is on its way. - */ - struct TrackerProgress - { - /** @brief Changes on their way to the server: waiting for the check, an answer or the queue, or running. */ - std::uint64_t pending{0}; - /** @brief Changes that ended since nothing was on its way any more. */ - std::uint64_t done{0}; - /** @brief Changes among the pending ones that wait for the user's answer to whether the server's copy goes. */ - std::uint64_t waitingForAnswer{0}; - - bool operator==(TrackerProgress const&) const = default; - }; - BOOST_DESCRIBE_STRUCT(TrackerProgress, (), (pending, done, waitingForAnswer)) -} From b669853ab325498bc3928b448a98f098c940be26 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:51:23 +0200 Subject: [PATCH 088/114] Removed the answer RPC, Tracker::answer and the server changed question types - Dropped SharedData::ServerChangedQuestion, ServerChangedAnswer and Ids::QuestionId, nothing else used them --- .../include/backend/file_tracking/tracker.hpp | 6 ----- .../backend/file_tracking/tracking_state.hpp | 2 +- backend/include/backend/session.hpp | 3 ++- .../source/backend/file_tracking/tracker.cpp | 14 ---------- .../source/backend/session_file_tracking.cpp | 14 ---------- backend/test/backend/test_tracker.hpp | 1 - ids/include/ids/ids.hpp | 1 - .../file_tracking/server_changed_question.hpp | 27 ------------------- 8 files changed, 3 insertions(+), 65 deletions(-) delete mode 100644 shared_data/include/shared_data/file_tracking/server_changed_question.hpp diff --git a/backend/include/backend/file_tracking/tracker.hpp b/backend/include/backend/file_tracking/tracker.hpp index 5c524ec6..396414ae 100644 --- a/backend/include/backend/file_tracking/tracker.hpp +++ b/backend/include/backend/file_tracking/tracker.hpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -160,11 +159,6 @@ namespace FileTracking std::expected discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& path); - /** - * @brief The user's answer to the question @p questionId, see TrackerView::ask(). - */ - void answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer); - /** * @brief The user's answer to the conflict @p conflictId, see TrackerView::showConflict(). ReplaceOnServer * checks the path again and uploads it, KeepServers asks again on the next save. Either removes the diff --git a/backend/include/backend/file_tracking/tracking_state.hpp b/backend/include/backend/file_tracking/tracking_state.hpp index 39ae509a..dfa89b62 100644 --- a/backend/include/backend/file_tracking/tracking_state.hpp +++ b/backend/include/backend/file_tracking/tracking_state.hpp @@ -617,7 +617,7 @@ namespace FileTracking /** * @brief The content of @p path as it is now is being handled, by the upload reading it or by the answer to - * a question about it: the changes that arrived so far need nothing more. + * a conflict about it: the changes that arrived so far need nothing more. */ void changesHandled(Paths::LocalPath const& path) { diff --git a/backend/include/backend/session.hpp b/backend/include/backend/session.hpp index a325c854..36a5c6c1 100644 --- a/backend/include/backend/session.hpp +++ b/backend/include/backend/session.hpp @@ -93,7 +93,8 @@ class Session * open (remote path, size, watch) answers once the copy is there with {instanceId, localPath}, an error to * show or neither, checkUploadAndWatch (local item, remote directory) with {item, destination}, * uploadAndWatch (local item, remote destination), stop (watch id), setOption (watch id, option, value), - * discard (instance id, local path) with {instanceDestroyed} and answer (question id, answer). See + * discard (instance id, local path) with {instanceDestroyed}, the answers to conflicts, retrying and + * dismissing failures, removing stopped watches and listing and removing orphaned copies. See * FileTracking::Tracker. */ void registerRpcFileTracker(); diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index aa330706..8e284c06 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -2197,15 +2197,6 @@ namespace FileTracking return true; } - void answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer) - { - Log::warn( - "FileTracking: ignoring the answer {} to question {}, conflicts are answered instead", - Utility::enumToString(answer), - questionId.value() - ); - } - void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) { auto listed = conflicts.remove(conflictId); @@ -2879,11 +2870,6 @@ namespace FileTracking return impl_->discard(instanceId, path); } - void Tracker::answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer) - { - impl_->answer(questionId, answer); - } - void Tracker::answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) { impl_->answerConflict(conflictId, resolution); diff --git a/backend/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index e2d832df..e69fe02c 100644 --- a/backend/source/backend/session_file_tracking.cpp +++ b/backend/source/backend/session_file_tracking.cpp @@ -960,20 +960,6 @@ void Session::registerRpcFileTracker() } ); - on(name("answer")) - .perform( - [withTracker]( - RpcHelper::RpcOnce&& reply, std::string const& questionId, SharedData::ServerChangedAnswer answer - ) - { - if (const auto self = withTracker(reply)) - { - self->tracker_->answer(Ids::makeQuestionId(questionId), answer); - reply({{"success", true}}); - } - } - ); - // Registers a function that calls @p call on the tracker and answers {success: true}. const auto onCall = [this, &name, &withTracker](std::string_view function, auto call) { diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 105ce1d0..03e87698 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -1433,7 +1433,6 @@ namespace Test ASSERT_EQ(view_->conflicts.size(), 1u); tracker_->answerConflict(Ids::generateConflictId(), SharedData::ConflictResolution::ReplaceOnServer); tracker_->answerAllConflicts(Ids::generateWatchId(), SharedData::ConflictResolution::ReplaceOnServer); - tracker_->answer(Ids::generateQuestionId(), SharedData::ServerChangedAnswer::Replace); EXPECT_EQ(uploadsTo("/srv/notes.txt"), 1u); EXPECT_TRUE(onlyEntry().uploading); diff --git a/ids/include/ids/ids.hpp b/ids/include/ids/ids.hpp index fd31c22b..d542da09 100644 --- a/ids/include/ids/ids.hpp +++ b/ids/include/ids/ids.hpp @@ -11,6 +11,5 @@ DEFINE_ID_TYPE(OperationId) DEFINE_ID_TYPE(InstanceId) DEFINE_ID_TYPE(WatchId) DEFINE_ID_TYPE(SyncSessionId) -DEFINE_ID_TYPE(QuestionId) DEFINE_ID_TYPE(ConflictId) DEFINE_ID_TYPE(FailureId) diff --git a/shared_data/include/shared_data/file_tracking/server_changed_question.hpp b/shared_data/include/shared_data/file_tracking/server_changed_question.hpp deleted file mode 100644 index ad67f904..00000000 --- a/shared_data/include/shared_data/file_tracking/server_changed_question.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace SharedData -{ - /** - * @brief The answer to a ServerChangedQuestion. Replace uploads the local version, Keep leaves the server's and - * asks again on the next save. Dismissed: another dialog took the place of the question, it and the - * waiting ones are asked again on the next save. - */ - BOOST_DEFINE_ENUM_CLASS(ServerChangedAnswer, Replace, Keep, Dismissed) - - /** - * @brief The tracker asks whether a local change may replace the server's copy at remotePath, which changed since - * it was opened or last uploaded. - */ - struct ServerChangedQuestion - { - Ids::QuestionId questionId; - Paths::RemotePath remotePath; - }; - BOOST_DESCRIBE_STRUCT(ServerChangedQuestion, (), (questionId, remotePath)) -} From 81fffe97b1479cba058832fcfffe91661d3c3d4b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:52:52 +0200 Subject: [PATCH 089/114] Removed the file tracking language keys of the old panel - columnName, columnRemotePath, columnStatus, serverChangedHeader/Text, statusUploading, headerActive, headerOrphaned, headerCleanupButton/Running and progressSyncing, which no code uses any more --- static/assets/languages/de_DE.yaml | 11 ----------- static/assets/languages/en_US.yaml | 11 ----------- 2 files changed, 22 deletions(-) diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index 2cba7124..2063e03e 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -503,24 +503,13 @@ snippetImportDialog: tally: "{} hinzugefügt · {} ersetzt · {} übersprungen" importDone: "Snippet-Import abgeschlossen. Hinzugefügt: {}, ersetzt: {}, übersprungen: {}, neue Ordner: {}." fileTracking: - columnName: "Name" - columnRemotePath: "Remote-Pfad" columnAutoReupload: "Auto-Hochladen" columnMoveRemote: "Remote verschieben" columnDeleteRemote: "Remote löschen" columnCheckRemote: "Remote prüfen" downloadedCopyOptionHint: "Eine temporäre Kopie verschiebt oder löscht die Datei auf dem Server nie." - serverChangedHeader: "Datei auf dem Server geändert" - serverChangedText: "Die Datei '{}' wurde auf dem Server geändert, nachdem Sie sie geöffnet oder zuletzt hochgeladen haben. Die Version auf dem Server durch die lokale ersetzen?\n\n'Nein' behält die Version auf dem Server. Beim nächsten Speichern werden Sie erneut gefragt." - columnStatus: "Status" stopButton: "Stopp" statusWatching: "Beobachtend" - statusUploading: "Hochladen..." - headerActive: "Aktiv" - headerOrphaned: "Verwaist" - headerCleanupButton: "Verwaiste bereinigen" - headerCleanupRunning: "Bereinige…" - progressSyncing: "Synchronisiere {} von {}" progressWaitingForAnswer: "{} warten auf Ihre Antwort" watchEndedCopyGone: "Die Beobachtung von {} wurde beendet, die temporäre Kopie wurde gelöscht oder umbenannt. Die Datei auf dem Server ist unverändert, mit 'Öffnen und Beobachten' können Sie sie erneut bearbeiten." watchesEndedConnectionLost: "Die Verbindung wurde unterbrochen, die Beobachtung von {} wurde beendet. Beobachten Sie sie erneut, sobald die Sitzung wieder verbunden ist." diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index 4eec952a..7b3a905f 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -503,24 +503,13 @@ snippetImportDialog: tally: "{} added · {} replaced · {} skipped" importDone: "Snippet import done. Added: {}, replaced: {}, skipped: {}, new folders: {}." fileTracking: - columnName: "Name" - columnRemotePath: "Remote Path" columnAutoReupload: "Auto Reupload" columnMoveRemote: "Move Remote" columnDeleteRemote: "Delete Remote" columnCheckRemote: "Check Remote" downloadedCopyOptionHint: "A temporary copy never moves or deletes the file on the server." - serverChangedHeader: "File Changed on the Server" - serverChangedText: "'{}' changed on the server after you opened or last uploaded it. Replace the server's version with your local one?\n\n'No' keeps the server's version. You will be asked again on the next save." - columnStatus: "Status" stopButton: "Stop" statusWatching: "Watching" - statusUploading: "Uploading..." - headerActive: "Active" - headerOrphaned: "Orphaned" - headerCleanupButton: "Clean Up Orphaned" - headerCleanupRunning: "Cleaning…" - progressSyncing: "Syncing {} of {}" progressWaitingForAnswer: "{} waiting for your answer" watchEndedCopyGone: "The watch of {} ended, its temporary copy was deleted or renamed. The file on the server is unchanged, use Open and Watch to edit it again." watchesEndedConnectionLost: "The connection was lost, the watches of {} ended. Watch them again once the session is connected." From 4273b3048c55c164e2b5d709acadf79d44d18fdf Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:53:13 +0200 Subject: [PATCH 090/114] Pointed the loader's hue ramp comment at the library's progress_color.hpp --- static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/index.html b/static/index.html index 820b947e..a6e20880 100644 --- a/static/index.html +++ b/static/index.html @@ -93,7 +93,7 @@ return n + " B"; }; - // Mirrors the hue ramp from frontend/source/frontend/components/progress_bar.cpp (red → green). + // Mirrors the hue ramp from script-nui-components/progress_color.hpp of 5cript-nui-components (red → green). const hueFor = (pct, done) => done ? 120 : Math.max(5, 120 * Math.pow(pct / 100, 1.8)); const updateProgress = (received, total, label) => { From 9ab56fd3bf1d40cbbf635a755f73df2132c53dae Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:57:09 +0200 Subject: [PATCH 091/114] Gave conflict and failure rows their kind, attempts and local path as test hooks --- .../session_components/file_tracking/conflicts_tab.hpp | 6 +++--- .../session_components/file_tracking/failures_tab.hpp | 8 ++++---- .../session_components/file_tracking/conflicts_tab.cpp | 3 +++ .../session_components/file_tracking/failures_tab.cpp | 2 ++ 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp index a8339bd5..3eb94c65 100644 --- a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp +++ b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp @@ -13,9 +13,9 @@ namespace FileTracking /** * @brief The Conflicts tab: an itemListBox counting item->conflictCount, with Replace All on Server * (data-ft-action=replace-all, Warning) and Keep All Server's (keep-all) calling actions.answerAllConflicts, - * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts (data-ft-conflict-id): - * the file, the line, Replace on Server (replace, Warning) and Keep Server's (keep) calling - * actions.answerConflict. + * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts (data-ft-conflict-id, + * data-ft-kind=, data-ft-path=): the file, the line, Replace on Server (replace, + * Warning) and Keep Server's (keep) calling actions.answerConflict. */ Nui::ElementRenderer conflictsTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp index 2327470a..4a10b06a 100644 --- a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp +++ b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp @@ -13,10 +13,10 @@ namespace FileTracking /** * @brief The Failed tab: an itemListBox counting item->failureCount, with Retry All (data-ft-action=retry-all, * Primary) and Dismiss All (dismiss-all), the hint notRetriedAutomatically and the empty text noFailures. - * One row per item->failures (data-ft-failure-id, data-ft-reason=): the file, the - * reason (its title the detail when there is one, plus failedTimes after more than one attempt), the time, - * Retry (retry, actions.retryFailure) and a dismiss icon button (dismiss, title dismiss, aria-label - * dismissFailureLabel, actions.dismissFailure). + * One row per item->failures (data-ft-failure-id, data-ft-reason=, data-ft-attempts, + * data-ft-path=): the file, the reason (its title the detail when there is one, plus + * failedTimes after more than one attempt), the time, Retry (retry, actions.retryFailure) and a dismiss + * icon button (dismiss, title dismiss, aria-label dismissFailureLabel, actions.dismissFailure). */ Nui::ElementRenderer failuresTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp index 211c065e..2c4883af 100644 --- a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,8 @@ namespace FileTracking class_ = "file-tracking-item-row file-tracking-conflict-row", role = "listitem", "data-ft-conflict-id"_attr = conflictId.value(), + "data-ft-kind"_attr = Utility::enumToString(item.conflict.kind), + "data-ft-path"_attr = item.conflict.localPath.toUtf8(), }( div{class_ = "file-tracking-item-row-text"}( div{class_ = "file-tracking-item-row-file", title = item.file}(item.file), diff --git a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp index 9b8b61ba..4bbae230 100644 --- a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp @@ -53,6 +53,8 @@ namespace FileTracking role = "listitem", "data-ft-failure-id"_attr = failureId.value(), "data-ft-reason"_attr = Utility::enumToString(item.failure.reason), + "data-ft-attempts"_attr = std::to_string(item.failure.attempts), + "data-ft-path"_attr = item.failure.localPath.toUtf8(), }( div{class_ = "file-tracking-item-row-text"}( div{class_ = "file-tracking-item-row-file", title = item.file}(item.file), From a8fb98f2cacdd10638e7018f8b7aec85085e67fb Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:57:09 +0200 Subject: [PATCH 092/114] Gave the sync progress bars their raw byte counts as test hooks --- .../file_tracking/sync_progress_block.hpp | 3 ++- .../file_tracking/sync_progress_block.cpp | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp index 2c61a629..27efa0f8 100644 --- a/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp +++ b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp @@ -14,7 +14,8 @@ namespace FileTracking * @brief The pinned progress of a watch (aria-label progressLabel). While Syncing: progressFiles and * progressWaiting (plus progressWaitingForAnswer while answers are due), a total progressBar in Bytes mode * on item->bytesDone and item->bytesTotal (data-ft-progress-total), and per row of item->inFlight its name - * and a small progressBar (data-ft-progress-file=). Otherwise a summary line (statusUpToDate, + * and a small progressBar (data-ft-progress-file=). Each bar has data-ft-bytes-done and + * data-ft-bytes-total. Otherwise a summary line (statusUpToDate, * progressUpToDateLastUpload, progressStoppedLastUpload, progressConflictsWait(One) or * progressFilesUpToDate) and, after a sync, a bar of the last sync. */ diff --git a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp index 26f3d1e7..0f3b761a 100644 --- a/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -191,6 +191,19 @@ namespace FileTracking }; } + /** + * @brief @p bytes as a raw number in an attribute that follows it, for the end-to-end tests. + */ + auto bytesAttributeValue(std::shared_ptr> const& bytes) + { + return Nui::observe(bytes).generate( + [bytes]() + { + return std::to_string(bytes->value()); + } + ); + } + /** * @brief One row of the files in flight: the path below the watched item and its bar. A free row keeps its * place (data-free, hidden by CSS), so the rows below it do not move. @@ -240,6 +253,8 @@ namespace FileTracking return row->path.value(); } ), + "data-ft-bytes-done"_attr = bytesAttributeValue(row->bytes), + "data-ft-bytes-total"_attr = bytesAttributeValue(row->totalBytes), }, }) ); @@ -335,6 +350,8 @@ namespace FileTracking .colorMode = ScriptNuiComponents::ProgressColorMode::Hue, .attributes = { "data-ft-progress-total"_attr = true, + "data-ft-bytes-done"_attr = bytesAttributeValue(item->bytesDone), + "data-ft-bytes-total"_attr = bytesAttributeValue(item->bytesTotal), }, }) ), From 0cd18d26a30c4e5e42c4030ac8beb531d32a6314 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 19:57:09 +0200 Subject: [PATCH 093/114] Marked copies and stopped watches in the detail and the stop text as test hooks --- .../file_tracking/stop_reason_strip.hpp | 9 +++++---- .../file_tracking/watch_detail.hpp | 5 +++-- .../file_tracking/stop_reason_strip.cpp | 6 ++++++ .../file_tracking/watch_detail.cpp | 13 +++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp index 530de047..0f5228a7 100644 --- a/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp +++ b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp @@ -11,10 +11,11 @@ namespace FileTracking { /** - * @brief Why a watch stopped: a messageStrip (data-ft-reason=) with item->stopText, mounted always - * and hidden while the watch runs. Its actions slot holds Show “Y” (data-ft-action=show-other-watch, text - * showOtherWatch, actions.showWatch) for an Overlap stop that names the other watch, and Open Again - * (open-again, actions.openAgain) for any other stopped copy. Stopped originals get no action. + * @brief Why a watch stopped: a messageStrip (data-ft-reason=, data-ft-stop-text=) + * with item->stopText, mounted always and hidden while the watch runs. Its actions slot holds Show “Y” + * (data-ft-action=show-other-watch, text showOtherWatch, actions.showWatch) for an Overlap stop that names + * the other watch, and Open Again (open-again, actions.openAgain) for any other stopped copy. Stopped + * originals get no action. */ Nui::ElementRenderer stopReasonStrip(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp b/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp index 83eed467..eec5a728 100644 --- a/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp +++ b/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp @@ -11,8 +11,9 @@ namespace FileTracking { /** - * @brief The detail pane of a watch (data-ft-detail=watch, data-ft-watch-id): watchHeader, stopReasonStrip, - * syncProgressBlock and detailTabs, top to bottom. + * @brief The detail pane of a watch (data-ft-detail=watch, data-ft-watch-id, data-ft-copy for a downloaded copy, + * data-ft-stopped while stopped): watchHeader, stopReasonStrip, syncProgressBlock and detailTabs, top to + * bottom. */ Nui::ElementRenderer watchDetail(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp index bd05a290..c99de6c3 100644 --- a/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp +++ b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp @@ -125,6 +125,12 @@ namespace FileTracking return stop ? Utility::enumToString(stop->reason) : std::string{}; } ), + "data-ft-stop-text"_attr = Nui::observe(item->stopText).generate( + [item]() + { + return item->stopText.value(); + } + ), hidden = Nui::observe(item->stop).generate( [item]() { diff --git a/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp index 4f35eb0c..3c3844e9 100644 --- a/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp +++ b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -24,6 +25,18 @@ namespace FileTracking class_ = "file-tracking-detail", "data-ft-detail"_attr = "watch", "data-ft-watch-id"_attr = item->watchId.value(), + "data-ft-copy"_attr = Nui::observe(item->downloadedCopy).generate( + [item]() + { + return item->downloadedCopy.value(); + } + ), + "data-ft-stopped"_attr = Nui::observe(item->stop).generate( + [item]() + { + return item->stop.value().has_value(); + } + ), }( watchHeader(store, actions, item), stopReasonStrip(store, actions, item), From 3270baa313054efc84d75884291ea64dc2499505 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 20:13:23 +0200 Subject: [PATCH 094/114] Selected a new watch in the file tracking panel while nothing is selected --- .../session_components/file_tracking/tracking_store.hpp | 3 ++- .../session_components/file_tracking/tracking_store.cpp | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp index 13685f6e..cbb9f2a0 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp @@ -36,7 +36,8 @@ namespace FileTracking ROAR_PIMPL_SPECIAL_FUNCTIONS_NO_MOVE(TrackingStore); /** - * @brief Adds or updates the item of @p entry, moving it between groups as its status changes. + * @brief Adds or updates the item of @p entry, moving it between groups as its status changes. A new watch that + * is not stopped is selected while nothing is. */ void showEntry(SharedData::TrackedEntry const& entry); diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp index 3062afa6..6a3c89da 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp @@ -558,6 +558,8 @@ namespace FileTracking impl_->model.showEntry(entry); impl_->refreshWatch(entry.watchId, inserted ? everyPart : WatchParts{.entry = true}); + if (inserted && !entry.stop && std::holds_alternative(impl_->selection.value())) + impl_->selection = Selection{entry.watchId}; if (heldInstance || entry.instanceId) impl_->refreshOrphans(); impl_->notifyAttention(); From fb82775e5f594166a2522d9c19c88cb51f389457 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 20:12:04 +0200 Subject: [PATCH 095/114] Gave log messages a toast choice that backend messages carry to the frontend - Log::Toast {Show, Skip}; log, warn, error and critical take it first, the old signatures show the toast - The backend sends it with the log RPC and keeps it in the stash, the frontend skips its hooks for Skip --- backend/test/backend/main.cpp | 1 + backend/test/backend/test_logger.hpp | 79 ++++++++++++++++++++++++++++ log/include/log/log.hpp | 49 +++++++++++++++-- log/include/log/logger_backend.hpp | 33 ++++++++++-- log/include/log/logger_frontend.hpp | 42 +++++++++++---- log/include/log/toast.hpp | 14 +++++ log/source/log/logger_backend.cpp | 16 ++++-- 7 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 backend/test/backend/test_logger.hpp create mode 100644 log/include/log/toast.hpp diff --git a/backend/test/backend/main.cpp b/backend/test/backend/main.cpp index dcfab564..c5f68c24 100644 --- a/backend/test/backend/main.cpp +++ b/backend/test/backend/main.cpp @@ -52,6 +52,7 @@ #include "test_program_options.hpp" #include "test_rpc_helper.hpp" #include "test_rpc_path_parameters.hpp" +#include "test_logger.hpp" #include diff --git a/backend/test/backend/test_logger.hpp b/backend/test/backend/test_logger.hpp new file mode 100644 index 00000000..fdb791b3 --- /dev/null +++ b/backend/test/backend/test_logger.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include + +#include + +#include +#include +#include + +namespace Test::LoggerCases +{ + /** + * @brief The latest message @p message the global logger keeps for the frontend, none when it keeps none such. + */ + inline std::optional stashedGlobally(std::string_view message) + { + const auto stashed = Log::Detail::logger.stashed(); + const auto found = std::find_if( + stashed.rbegin(), + stashed.rend(), + [message](Log::Logger::StashedMessage const& stashedMessage) + { + return stashedMessage.message == message; + } + ); + if (found == stashed.rend()) + return std::nullopt; + return *found; + } + + TEST(LoggerTest, AMessageStashedBeforeTheFrontendIsReadyKeepsWhetherItSkipsTheToast) + { + Log::Logger logger{}; + + logger.log(Log::Level::Error, Log::Toast::Skip, "Listed in a panel {}", 1); + logger.log(Log::Level::Error, "Shown as a toast {}", 2); + + const auto stashed = logger.stashed(); + ASSERT_EQ(stashed.size(), 2u); + EXPECT_EQ(stashed[0].level, Log::Level::Error); + EXPECT_EQ(stashed[0].toast, Log::Toast::Skip); + EXPECT_EQ(stashed[0].message, "Listed in a panel 1"); + EXPECT_EQ(stashed[1].toast, Log::Toast::Show); + EXPECT_EQ(stashed[1].message, "Shown as a toast 2"); + } + + TEST(LoggerTest, TheConvenienceFunctionsHandTheToastOn) + { + Log::error(Log::Toast::Skip, "Skipped error {}", 3); + Log::warn(Log::Toast::Skip, "Skipped warning"); + Log::critical(Log::Toast::Skip, "Skipped critical"); + Log::log(Log::Level::Error, Log::Toast::Skip, "Skipped at a level of its own"); + Log::error("Shown error"); + + const auto error = stashedGlobally("Skipped error 3"); + ASSERT_TRUE(error.has_value()); + EXPECT_EQ(error->level, Log::Level::Error); + EXPECT_EQ(error->toast, Log::Toast::Skip); + + const auto warning = stashedGlobally("Skipped warning"); + ASSERT_TRUE(warning.has_value()); + EXPECT_EQ(warning->level, Log::Level::Warning); + EXPECT_EQ(warning->toast, Log::Toast::Skip); + + const auto critical = stashedGlobally("Skipped critical"); + ASSERT_TRUE(critical.has_value()); + EXPECT_EQ(critical->level, Log::Level::Critical); + EXPECT_EQ(critical->toast, Log::Toast::Skip); + + const auto atLevel = stashedGlobally("Skipped at a level of its own"); + ASSERT_TRUE(atLevel.has_value()); + EXPECT_EQ(atLevel->toast, Log::Toast::Skip); + + const auto shown = stashedGlobally("Shown error"); + ASSERT_TRUE(shown.has_value()); + EXPECT_EQ(shown->toast, Log::Toast::Show); + } +} diff --git a/log/include/log/log.hpp b/log/include/log/log.hpp index 04c23239..0df891c2 100644 --- a/log/include/log/log.hpp +++ b/log/include/log/log.hpp @@ -2,6 +2,7 @@ #include #include +#include #ifdef __EMSCRIPTEN__ # include @@ -32,11 +33,12 @@ namespace Log * * @tparam Args Format template arguments. * @param level Log level. + * @param toast Whether the frontend's log hooks see it, which show errors as toasts. * @param fmt Format string. * @param args Format string arguments. */ template - void log(Log::Level level, std::string_view fmt, Args&&... args) + void log(Log::Level level, Log::Toast toast, std::string_view fmt, Args&&... args) { #ifdef __EMSCRIPTEN__ const auto callable = Nui::RpcClient::getRemoteCallable("log"); @@ -44,12 +46,26 @@ namespace Log callable(static_cast(level), Logger::format(fmt, std::forward(args)...)); if (Detail::logger) - Detail::logger->log(level, fmt, std::forward(args)...); + Detail::logger->log(level, toast, fmt, std::forward(args)...); #else - Detail::logger.log(level, fmt, std::forward(args)...); + Detail::logger.log(level, toast, fmt, std::forward(args)...); #endif } + /** + * @brief Log a message, shown as a toast when it is an error. + * + * @tparam Args Format template arguments. + * @param level Log level. + * @param fmt Format string. + * @param args Format string arguments. + */ + template + void log(Log::Level level, std::string_view fmt, Args&&... args) + { + log(level, Log::Toast::Show, fmt, std::forward(args)...); + } + #ifndef __EMSCRIPTEN__ /** * @brief Setups the backend rpc hub for logging. @@ -130,6 +146,15 @@ namespace Log return log(Log::Level::Warning, std::move(fmt), std::forward(args)...); } + /** + * @brief Convenience function to log at warn level, @p toast decides whether the log hooks see it. + */ + template + inline void warn(Log::Toast toast, std::string_view fmt, Args&&... args) + { + return log(Log::Level::Warning, toast, std::move(fmt), std::forward(args)...); + } + /** * @brief Convenience function to log at error level. */ @@ -139,6 +164,15 @@ namespace Log return log(Log::Level::Error, std::move(fmt), std::forward(args)...); } + /** + * @brief Convenience function to log at error level, @p toast decides whether the log hooks see it. + */ + template + inline void error(Log::Toast toast, std::string_view fmt, Args&&... args) + { + return log(Log::Level::Error, toast, std::move(fmt), std::forward(args)...); + } + /** * @brief Convenience function to log at critical level. */ @@ -147,4 +181,13 @@ namespace Log { return log(Log::Level::Critical, std::move(fmt), std::forward(args)...); } + + /** + * @brief Convenience function to log at critical level, @p toast decides whether the log hooks see it. + */ + template + inline void critical(Log::Toast toast, std::string_view fmt, Args&&... args) + { + return log(Log::Level::Critical, toast, std::move(fmt), std::forward(args)...); + } } \ No newline at end of file diff --git a/log/include/log/logger_backend.hpp b/log/include/log/logger_backend.hpp index e6c3c447..bec734fd 100644 --- a/log/include/log/logger_backend.hpp +++ b/log/include/log/logger_backend.hpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -15,9 +16,20 @@ namespace Log class Logger { public: + /** + * @brief A message kept until the frontend logger is ready. + */ + struct StashedMessage + { + Log::Level level; + Log::Toast toast; + std::string message; + }; + Logger() : guard_{} , rpcHub_{nullptr} + , rpcHubPrelim_{nullptr} , stash_{} , levelStashed_{Level::Info} {} @@ -28,24 +40,35 @@ namespace Log Log::Level level() const; template - void log(Log::Level level, std::string_view fmt, Args&&... args) + void log(Log::Level level, Log::Toast toast, std::string_view fmt, Args&&... args) { const std::string buf = spdlog::fmt_lib::format(spdlog::fmt_lib::runtime(fmt), std::forward(args)...); - logImpl(level, buf); + logImpl(level, toast, buf); + } + + template + void log(Log::Level level, std::string_view fmt, Args&&... args) + { + log(level, Log::Toast::Show, fmt, std::forward(args)...); } void detach(); + /** + * @brief The messages kept until the frontend logger is ready, oldest first. + */ + std::vector stashed() const; + private: - void logImpl(Log::Level level, std::string const& msg); + void logImpl(Log::Level level, Log::Toast toast, std::string const& msg); private: - std::recursive_mutex guard_; + mutable std::recursive_mutex guard_; Nui::RpcHub* rpcHub_; Nui::RpcHub* rpcHubPrelim_; // for when the rpc hub is not yet available - std::vector> stash_; + std::vector stash_; Log::Level levelStashed_; }; } \ No newline at end of file diff --git a/log/include/log/logger_frontend.hpp b/log/include/log/logger_frontend.hpp index 7a09ef16..cdefc4f8 100644 --- a/log/include/log/logger_frontend.hpp +++ b/log/include/log/logger_frontend.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -41,7 +42,7 @@ namespace Log }; /** - * @brief Installs a hook for every message at or above @p minimumLevel. + * @brief Installs a hook for every message at or above @p minimumLevel, except those logged with Toast::Skip. * * Hooks see messages the log level filters out, so turning the log down does not silence them. * A message logged from inside a hook does not reach the hooks again. @@ -101,10 +102,11 @@ namespace Log , logOnConsole_{logOnConsole} , autoUnregisterOnLog_{Nui::RpcClient::autoRegisterFunction( "log", - [this](int integralLevel, std::string const& message) + [this](int integralLevel, std::string const& message, bool showToast) { - using namespace std::string_literals; - log(static_cast(integralLevel), "[MAIN] "s + message); + log(static_cast(integralLevel), + showToast ? Log::Toast::Show : Log::Toast::Skip, + fmt::format("[MAIN] {}", message)); } )} , autoUnregisterSetLevel_{Nui::RpcClient::autoRegisterFunction( @@ -133,10 +135,13 @@ namespace Log return logLevel_; } - template - void log(Log::Level level, std::string const& message) + /** + * @brief Logs the formatted @p message, the hooks see it only with Toast::Show. + */ + void log(Log::Level level, Log::Toast toast, std::string const& message) { - Detail::runHooks(level, message); + if (toast == Log::Toast::Show) + Detail::runHooks(level, message); if (level < logLevel_) return; @@ -171,14 +176,25 @@ namespace Log } template - void log(Log::Level level, std::string_view fmt, Args&&... args) + void log(Log::Level level, std::string const& message) + { + log(level, Log::Toast::Show, message); + } + + /** + * @brief Formats and logs a message, the hooks see it only with Toast::Show. + */ + template + void log(Log::Level level, Log::Toast toast, std::string_view fmt, Args&&... args) { const bool logged = level >= logLevel_; - if (!logged && !Detail::hooksWant(level)) + const bool hooked = toast == Log::Toast::Show && Detail::hooksWant(level); + if (!logged && !hooked) return; const auto message = format(fmt, std::forward(args)...); - Detail::runHooks(level, message); + if (hooked) + Detail::runHooks(level, message); if (!logged) return; @@ -215,6 +231,12 @@ namespace Log } } + template + void log(Log::Level level, std::string_view fmt, Args&&... args) + { + log(level, Log::Toast::Show, fmt, std::forward(args)...); + } + template static std::string format(std::string_view fmt, Args&&... args) { diff --git a/log/include/log/toast.hpp b/log/include/log/toast.hpp new file mode 100644 index 00000000..7502f390 --- /dev/null +++ b/log/include/log/toast.hpp @@ -0,0 +1,14 @@ +#pragma once + +namespace Log +{ + /** + * @brief Whether a message also reaches the frontend's log hooks, which show errors to the user as toasts. The log + * and the console get it either way. Failures the user is shown elsewhere, or that are retried, skip it. + */ + enum class Toast + { + Show, + Skip, + }; +} diff --git a/log/source/log/logger_backend.cpp b/log/source/log/logger_backend.cpp index 9849cdd0..4e5fbfad 100644 --- a/log/source/log/logger_backend.cpp +++ b/log/source/log/logger_backend.cpp @@ -79,8 +79,8 @@ namespace Log if (!stash_.empty()) { - for (auto& s : stash_) - logImpl(s.first, s.second); + for (auto const& stashed : stash_) + logImpl(stashed.level, stashed.toast, stashed.message); stash_.clear(); } rpcHub_->callRemote("setLogLevel", static_cast(levelStashed_)); @@ -127,7 +127,13 @@ namespace Log stash_.clear(); } - void Logger::logImpl(Log::Level level, std::string const& msg) + std::vector Logger::stashed() const + { + std::scoped_lock lock{guard_}; + return stash_; + } + + void Logger::logImpl(Log::Level level, Log::Toast toast, std::string const& msg) { decltype(rpcHub_) hub; { @@ -138,7 +144,7 @@ namespace Log { try { - hub->callRemote("log", static_cast(level), Paths::sanitizedUtf8(msg)); + hub->callRemote("log", static_cast(level), Paths::sanitizedUtf8(msg), toast == Log::Toast::Show); } catch (const std::exception& e) { @@ -154,7 +160,7 @@ namespace Log // Do not accumulate logs in tests: if (level != Level::Off) { - stash_.emplace_back(level, msg); + stash_.push_back({.level = level, .toast = toast, .message = msg}); } } From b8af31148adb6cb0b7298e0a2b9ea1452c0d5bb6 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 20:19:15 +0200 Subject: [PATCH 096/114] Logged the failures of tracked uploads without a toast, the panel lists them - addUploadOperation and addBulkUploadOperation take a failureToast, which the upload, bulk and scan options carry - The queue logs "Operation failed" of such an operation without a toast; FileTrackingRemote passes Skip --- .../backend/sftp/bulk_transfer_operation.hpp | 6 + .../backend/sftp/compared_state_check.hpp | 5 +- .../backend/sftp/local_scan_operation.hpp | 7 + .../include/backend/sftp/operation_queue.hpp | 18 ++- .../include/backend/sftp/upload_operation.hpp | 6 + .../source/backend/file_tracking/tracker.cpp | 1 + .../source/backend/session_file_tracking.cpp | 7 +- .../backend/sftp/bulk_transfer_operation.cpp | 29 ++-- .../backend/sftp/bulk_upload_operation.cpp | 7 +- .../backend/sftp/compared_state_check.cpp | 7 +- .../backend/sftp/local_scan_operation.cpp | 15 +- .../source/backend/sftp/operation_queue.cpp | 45 ++++-- .../source/backend/sftp/upload_operation.cpp | 141 ++++++++++++------ backend/test/backend/test_operation_queue.hpp | 131 ++++++++++++++++ 14 files changed, 351 insertions(+), 74 deletions(-) diff --git a/backend/include/backend/sftp/bulk_transfer_operation.hpp b/backend/include/backend/sftp/bulk_transfer_operation.hpp index 33121788..605f7e86 100644 --- a/backend/include/backend/sftp/bulk_transfer_operation.hpp +++ b/backend/include/backend/sftp/bulk_transfer_operation.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,11 @@ class BulkTransferOperation : public Operation std::chrono::seconds futureTimeout{5}; // Minimum gap between two progress emissions; start and completion always emit. std::chrono::milliseconds progressEmitInterval{100}; + /** + * @brief Whether the errors the bulk logs when it or one of its entries fails are shown as toasts, see + * UploadOperation::UploadOperationOptions::failureToast. + */ + Log::Toast failureToast{Log::Toast::Show}; }; /** diff --git a/backend/include/backend/sftp/compared_state_check.hpp b/backend/include/backend/sftp/compared_state_check.hpp index a4997815..e514e039 100644 --- a/backend/include/backend/sftp/compared_state_check.hpp +++ b/backend/include/backend/sftp/compared_state_check.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -67,6 +68,7 @@ namespace ComparedStateCheck /** * @brief SftpSession::moveIntoPlaceInStrand of a finished remote transfer, once * @ref requireUnchangedRemoteInStrand passed for @p to. + * @param failureToast Whether the error logged when the move fails is shown as a toast. */ std::expected moveRemoteIntoPlaceInStrand( SecureShell::SftpSession& sftp, @@ -75,6 +77,7 @@ namespace ComparedStateCheck bool replace, std::string const& asideSuffix, SharedData::FileType sourceType, - std::optional const& compared + std::optional const& compared, + Log::Toast failureToast ); } diff --git a/backend/include/backend/sftp/local_scan_operation.hpp b/backend/include/backend/sftp/local_scan_operation.hpp index 8377d7c3..86240f04 100644 --- a/backend/include/backend/sftp/local_scan_operation.hpp +++ b/backend/include/backend/sftp/local_scan_operation.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,11 @@ class LocalScanOperation : public Operation bool buildTree{false}; /** @brief Tree mode only: names ending in one of these, the temp files of transfers, stay excluded. */ std::vector excludedSuffixes{}; + /** + * @brief Whether the errors the scan logs when it fails are shown as toasts, see + * UploadOperation::UploadOperationOptions::failureToast. + */ + Log::Toast failureToast{Log::Toast::Show}; }; LocalScanOperation(ScanOperationOptions options); @@ -197,6 +203,7 @@ class LocalScanOperation : public Operation bool ignoreHidden_; bool buildTree_; std::vector excludedSuffixes_; + Log::Toast failureToast_; bool rootScanned_{false}; SharedData::IgnoreMatcher ignoreMatcher_; std::unique_ptr walker_; diff --git a/backend/include/backend/sftp/operation_queue.hpp b/backend/include/backend/sftp/operation_queue.hpp index 2df46b1f..36976419 100644 --- a/backend/include/backend/sftp/operation_queue.hpp +++ b/backend/include/backend/sftp/operation_queue.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -147,6 +148,8 @@ class OperationQueue * @param adoptPartialOf The destination of a cancelled upload of the file under its old name, whose temporary * file this one continues, see UploadOperation::UploadOperationOptions::adoptPartialOf. A directory * ignores it. + * @param failureToast Whether the errors logged when the upload is refused or fails are shown as toasts, see + * UploadOperation::UploadOperationOptions::failureToast. */ std::expected addUploadOperation( SecureShell::SftpSession& sftp, @@ -161,7 +164,8 @@ class OperationQueue bool sourceMayVanish = false, std::optional comparedDestination = std::nullopt, UploadOperation::OnSent onSent = {}, - std::optional adoptPartialOf = std::nullopt + std::optional adoptPartialOf = std::nullopt, + Log::Toast failureToast = Log::Toast::Show ); /** @@ -260,6 +264,8 @@ class OperationQueue * @brief Upload analogue of @ref addBulkDownloadOperation. * @param sourceMayVanish Each entry may be gone by the time it is uploaded, see addUploadOperation. * @param onSent Hears of each regular file sent unchanged, see addUploadOperation. + * @param failureToast Whether the errors logged when an entry is refused or fails are shown as toasts, see + * addUploadOperation. */ std::size_t addBulkUploadOperation( SecureShell::SftpSession& sftp, @@ -267,7 +273,8 @@ class OperationQueue std::function const& operationIdFor, Ids::OperationId const& bulkCardId, bool sourceMayVanish = false, - UploadOperation::OnSent const& onSent = {} + UploadOperation::OnSent const& onSent = {}, + Log::Toast failureToast = Log::Toast::Show ); /** @@ -431,6 +438,11 @@ class OperationQueue auto makeBulkProgressCallback(std::string_view eventName, Ids::OperationId operationId) -> BulkProgressCallback; + /** + * @brief Remembers whether the failure of the queued operation @p operationId is logged with a toast. + */ + void rememberFailureToast(Ids::OperationId const& operationId, Log::Toast failureToast); + /** * @brief Sends the delete progress of @p operationId (current item, items handled, items in all) to the frontend. */ @@ -524,6 +536,8 @@ class OperationQueue std::unordered_map progressObservers_{}; /** @brief See leaveChangedDestinationsToOwner(). */ std::unordered_set changedDestinationsLeftToOwner_{}; + /** @brief The operations whose failure is logged without a toast, see rememberFailureToast(). */ + std::unordered_set failuresWithoutToast_{}; // Transfer slots handed out per queue tick. int parallelism_{1}; // Upper bound on the slots a single bulk transfer may take, clamped to parallelism_. diff --git a/backend/include/backend/sftp/upload_operation.hpp b/backend/include/backend/sftp/upload_operation.hpp index 23b544cd..20908581 100644 --- a/backend/include/backend/sftp/upload_operation.hpp +++ b/backend/include/backend/sftp/upload_operation.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -95,6 +96,11 @@ class UploadOperation : public Operation * becomes this upload's before it is continued, see tryContinue. Unless this upload has one already. */ std::optional adoptPartialOf{}; + /** + * @brief Whether the errors this upload logs when it fails are shown as toasts. Skipped for an owner that + * lists the failures itself, see FileTracking::Tracker. + */ + Log::Toast failureToast{Log::Toast::Show}; }; SecureShell::ProcessingStrand* strand() const override; diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 8e284c06..6e754809 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -2139,6 +2139,7 @@ namespace FileTracking void onUncheckable(Upload const& upload, std::error_code const& error) { Log::error( + Log::Toast::Skip, "FileTracking: not uploading '{}', it cannot be checked: {}", upload.change.path.toUtf8(), Utility::errorMessage(error) diff --git a/backend/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index e69fe02c..2b934a5d 100644 --- a/backend/source/backend/session_file_tracking.cpp +++ b/backend/source/backend/session_file_tracking.cpp @@ -354,7 +354,9 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote /*sourceMayVanish=*/true, comparedStateOf(request.expectedDestination), sent->recorder(), - request.continuesPartialOf + request.continuesPartialOf, + // The File Tracking panel lists the failure. + Log::Toast::Skip ); // The tracker asks the user about a server copy that changed meanwhile. if (added) @@ -492,7 +494,8 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote }, cardId, /*sourceMayVanish=*/true, - sent->recorder() + sent->recorder(), + Log::Toast::Skip ); // The tracker decides anew what goes once the session is back. queue.discardResumeBackup(cardId); diff --git a/backend/source/backend/sftp/bulk_transfer_operation.cpp b/backend/source/backend/sftp/bulk_transfer_operation.cpp index cbbb4b97..76192e29 100644 --- a/backend/source/backend/sftp/bulk_transfer_operation.cpp +++ b/backend/source/backend/sftp/bulk_transfer_operation.cpp @@ -65,7 +65,7 @@ std::expected B ); if (fut.wait_for(common_.futureTimeout) != std::future_status::ready) { - Log::error("{}: work umbrella timed out.", logName()); + Log::error(common_.failureToast, "{}: work umbrella timed out.", logName()); return enterErrorState({.type = ErrorType::FutureTimeout}); } return fut.get(); @@ -77,7 +77,7 @@ std::expected B if (scanPhase_) { - Log::error("{}: The scan phase cannot run on the processing thread.", logName()); + Log::error(common_.failureToast, "{}: The scan phase cannot run on the processing thread.", logName()); return enterErrorState( Error{.type = ErrorType::ImplementationError, .extraInfo = "Scan phase stepped on the processing thread."} ); @@ -123,7 +123,7 @@ std::expected B return std::unexpected(Error{.type = ErrorType::CannotWorkCompletedOperation}); } } - Log::error("{}: Unknown operation state: {}", logName(), static_cast(state())); + Log::error(common_.failureToast, "{}: Unknown operation state: {}", logName(), static_cast(state())); return enterErrorState({.type = ErrorType::UnknownWorkState}); } @@ -132,7 +132,7 @@ BulkTransferOperation::start(SecureShell::SftpSession& sftp) { if (entrySource_ == EntrySource::None) { - Log::error("{}: Started without entries.", logName()); + Log::error(common_.failureToast, "{}: Started without entries.", logName()); return enterErrorState( Error{.type = ErrorType::ImplementationError, .extraInfo = "Neither a scan result nor a file list was set."} ); @@ -141,7 +141,7 @@ BulkTransferOperation::start(SecureShell::SftpSession& sftp) { if (entrySource_ == EntrySource::Tree) { - Log::error("{}: The scan delivered no entries.", logName()); + Log::error(common_.failureToast, "{}: The scan delivered no entries.", logName()); return enterErrorState(Error{.type = ErrorType::ScanIncomplete}); } Log::info("{}: No entries to transfer.", logName()); @@ -160,14 +160,14 @@ BulkTransferOperation::start(SecureShell::SftpSession& sftp) auto const& root = entries_[0]; if (!root.isDirectory() || root.parent) { - Log::error("{}: The first entry is not the root directory of the scan.", logName()); + Log::error(common_.failureToast, "{}: The first entry is not the root directory of the scan.", logName()); return enterErrorState( Error{.type = ErrorType::ImplementationError, .extraInfo = "First entry must be a directory."} ); } if (common_.remotePath.empty() || common_.localPath.empty()) { - Log::error("{}: A scanned tree needs a remote and a local root.", logName()); + Log::error(common_.failureToast, "{}: A scanned tree needs a remote and a local root.", logName()); return enterErrorState( Error{.type = ErrorType::ImplementationError, .extraInfo = "A tree needs both roots."} ); @@ -189,7 +189,7 @@ BulkTransferOperation::step(SecureShell::SftpSession& sftp) { if (cursor_ > entries_.size()) { - Log::error("{}: Cursor out of range.", logName()); + Log::error(common_.failureToast, "{}: Cursor out of range.", logName()); return enterErrorState(Error{ .type = ErrorType::ImplementationError, .extraInfo = "Bulk transfer cursor is beyond the item count, which should never occur." @@ -353,6 +353,7 @@ std::expected BulkTransferOperation::stepSlo Log::log( failureLevelOf(error), + common_.failureToast, "{}: Transfer failed for: {}: {}", logName(), slot.paths.remote.value(), @@ -366,7 +367,11 @@ std::expected BulkTransferOperation::stepSlo finishSlot(slot, false); if (common_.failFast) { - Log::error("{}: failFast is enabled, failing the whole operation due to the error.", logName()); + Log::error( + common_.failureToast, + "{}: failFast is enabled, failing the whole operation due to the error.", + logName() + ); return std::unexpected(error); } continue; @@ -725,7 +730,11 @@ std::expected BulkTransferOperation::cancel( } ); if (fut.wait_for(common_.futureTimeout) != std::future_status::ready) - Log::error("{}: cancel umbrella timed out, children stay with the processing thread.", logName()); + Log::error( + common_.failureToast, + "{}: cancel umbrella timed out, children stay with the processing thread.", + logName() + ); } else { diff --git a/backend/source/backend/sftp/bulk_upload_operation.cpp b/backend/source/backend/sftp/bulk_upload_operation.cpp index f5407df1..c2ef1cd0 100644 --- a/backend/source/backend/sftp/bulk_upload_operation.cpp +++ b/backend/source/backend/sftp/bulk_upload_operation.cpp @@ -21,6 +21,7 @@ namespace .concurrency = options.concurrency, .futureTimeout = options.individualOptions.futureTimeout, .progressEmitInterval = options.progressEmitInterval, + .failureToast = options.individualOptions.failureToast, }; } } @@ -119,7 +120,11 @@ std::expected BulkUploadOperation::createRemot const auto result = sftp.createDirectoryIfItDoesntExistInStrand(path, determinePerms(entry)); if (!result.has_value()) { - Log::error("BulkUploadOperation: Failed to create remote sftp directory: {}.", result.error().message); + Log::error( + individualOptions_.failureToast, + "BulkUploadOperation: Failed to create remote sftp directory: {}.", + result.error().message + ); return std::unexpected( Error{ .type = ErrorType::SftpError, diff --git a/backend/source/backend/sftp/compared_state_check.cpp b/backend/source/backend/sftp/compared_state_check.cpp index 91c15cb7..0a0018ac 100644 --- a/backend/source/backend/sftp/compared_state_check.cpp +++ b/backend/source/backend/sftp/compared_state_check.cpp @@ -177,7 +177,8 @@ namespace ComparedStateCheck bool replace, std::string const& asideSuffix, SharedData::FileType sourceType, - std::optional const& compared + std::optional const& compared, + Log::Toast failureToast ) { return requireUnchangedRemoteInStrand(sftp, to, compared) @@ -187,7 +188,9 @@ namespace ComparedStateCheck const auto moved = sftp.moveIntoPlaceInStrand(from, to, replace, asideSuffix, sourceType); if (!moved) { - Log::error("Failed to move '{}' into place: {}", to.value(), moved.error().message); + Log::error( + failureToast, "Failed to move '{}' into place: {}", to.value(), moved.error().message + ); return std::unexpected(Operation::remoteMoveError(moved.error())); } return {}; diff --git a/backend/source/backend/sftp/local_scan_operation.cpp b/backend/source/backend/sftp/local_scan_operation.cpp index 461a0ce2..7d2495e1 100644 --- a/backend/source/backend/sftp/local_scan_operation.cpp +++ b/backend/source/backend/sftp/local_scan_operation.cpp @@ -37,6 +37,7 @@ LocalScanOperation::LocalScanOperation(ScanOperationOptions options) , ignoreHidden_{options.ignoreHidden} , buildTree_{options.buildTree} , excludedSuffixes_{std::move(options.excludedSuffixes)} + , failureToast_{options.failureToast} {} LocalScanOperation::~LocalScanOperation() = default; @@ -91,6 +92,7 @@ LocalScanOperation::scanner(Paths::RelativePath const& relative) const auto failed = [this, &entry](std::error_code const& error) { Log::error( + failureToast_, "LocalScanOperation: Cannot inspect '{}': {}", Paths::nativeToUtf8(entry.path()), Utility::errorMessage(error) @@ -233,7 +235,10 @@ LocalScanOperation::listingFailed( ); } Log::error( - "LocalScanOperation: Failed to list '{}': {}", Paths::nativeToUtf8(path), Utility::errorMessage(errorCode) + failureToast_, + "LocalScanOperation: Failed to list '{}': {}", + Paths::nativeToUtf8(path), + Utility::errorMessage(errorCode) ); return enterErrorState>( {.type = ErrorType::FilesystemError, .extraInfo = Utility::errorMessage(errorCode)} @@ -269,7 +274,7 @@ std::expected LocalSc { if (localPath_.empty()) { - Log::error("LocalScanOperation: No root to scan."); + Log::error(failureToast_, "LocalScanOperation: No root to scan."); return enterErrorState( {.type = ErrorType::ImplementationError, .extraInfo = "The scan has no root."} ); @@ -294,7 +299,9 @@ std::expected LocalSc auto result = walker.walk(); if (!result.has_value()) { - Log::error("LocalScanOperation: Failed to scan directory: {}", result.error().toString()); + Log::error( + failureToast_, "LocalScanOperation: Failed to scan directory: {}", result.error().toString() + ); return enterErrorState(result.error()); } // -1, because the walker includes the base/root dir of the search: @@ -308,7 +315,7 @@ std::expected LocalSc case (Prepared): case (Preparing): case (Finalizing): - Log::error("LocalScanOperation: Invalid state: {}", static_cast(state_)); + Log::error(failureToast_, "LocalScanOperation: Invalid state: {}", static_cast(state_)); return enterErrorState({.type = ErrorType::InvalidOperationState}); case (Completed): { diff --git a/backend/source/backend/sftp/operation_queue.cpp b/backend/source/backend/sftp/operation_queue.cpp index 4906fe11..da5e3f66 100644 --- a/backend/source/backend/sftp/operation_queue.cpp +++ b/backend/source/backend/sftp/operation_queue.cpp @@ -288,13 +288,14 @@ namespace /** * @brief Why the upload of @p localPath is refused, its status (@p status, @p statusError) unreadable. A source - * that may vanish and is gone is nothing that failed. + * that may vanish and is gone is nothing that failed. The error is logged with @p failureToast. */ Operation::Error refusedSource( Paths::LocalPath const& localPath, std::filesystem::file_status status, std::error_code const& statusError, - bool sourceMayVanish + bool sourceMayVanish, + Log::Toast failureToast ) { if (sourceMayVanish && status.type() == std::filesystem::file_type::not_found) @@ -302,7 +303,9 @@ namespace Log::debug("Not uploading '{}', it is gone.", localPath.toUtf8()); return Operation::Error{.type = Operation::ErrorType::FileNotFound}; } - Log::error("Failed to stat local file '{}': {}", localPath.toUtf8(), Utility::errorMessage(statusError)); + Log::error( + failureToast, "Failed to stat local file '{}': {}", localPath.toUtf8(), Utility::errorMessage(statusError) + ); return Operation::Error{.type = Operation::ErrorType::FileStatFailed}; } } @@ -647,9 +650,13 @@ void OperationQueue::completeOperation(SharedData::OperationCompleted&& operatio if (self->changedDestinationsLeftToOwner_.erase(operationCompleted.operationId) > 0) leaveChangesToOwner(operationCompleted); + const auto failureToast = self->failuresWithoutToast_.erase(operationCompleted.operationId) > 0 + ? Log::Toast::Skip + : Log::Toast::Show; if (operationCompleted.error) Log::log( Operation::failureLevelOf(*operationCompleted.error), + failureToast, "Operation failed: {}", operationCompleted.error->toString() ); @@ -697,6 +704,12 @@ void OperationQueue::leaveChangedDestinationsToOwner(Ids::OperationId const& ope changedDestinationsLeftToOwner_.insert(operationId); } +void OperationQueue::rememberFailureToast(Ids::OperationId const& operationId, Log::Toast failureToast) +{ + if (failureToast == Log::Toast::Skip) + failuresWithoutToast_.insert(operationId); +} + void OperationQueue::dumpBulkResumes(BulkResumeRegistry& registry) { if (bulkResumeStash_.empty()) @@ -1676,7 +1689,8 @@ std::expected OperationQueue::addUploadOperation( bool sourceMayVanish, std::optional comparedDestination, UploadOperation::OnSent onSent, - std::optional adoptPartialOf + std::optional adoptPartialOf, + Log::Toast failureToast ) { // Assumed in strand @@ -1689,7 +1703,7 @@ std::expected OperationQueue::addUploadOperation( const auto symStat = std::filesystem::symlink_status(localNativePath, ec); if (ec) - return std::unexpected(refusedSource(localPath, symStat, ec, sourceMayVanish)); + return std::unexpected(refusedSource(localPath, symStat, ec, sourceMayVanish, failureToast)); const bool isSymlink = std::filesystem::is_symlink(symStat); std::filesystem::file_status stat = symStat; @@ -1697,7 +1711,7 @@ std::expected OperationQueue::addUploadOperation( { stat = std::filesystem::status(localNativePath, ec); if (ec) - return std::unexpected(refusedSource(localPath, stat, ec, sourceMayVanish)); + return std::unexpected(refusedSource(localPath, stat, ec, sourceMayVanish, failureToast)); } auto transferOptions = sftpOpts_.uploadOptions.value_or(Persistence::UploadOptions{}); @@ -1720,6 +1734,7 @@ std::expected OperationQueue::addUploadOperation( opts.comparedDestination = std::move(comparedDestination); opts.onSent = std::move(onSent); opts.adoptPartialOf = std::move(adoptPartialOf); + opts.failureToast = failureToast; opts.progressCallback = [weak = weak_from_this(), operationId, name = rpcName("onUploadProgress")]( auto min, auto max, auto current, auto bytesPerSecond ) @@ -1751,6 +1766,7 @@ std::expected OperationQueue::addUploadOperation( targetQueue.emplace_back( operationId, std::make_unique(sftp.weak_from_this(), std::move(opts)) ); + rememberFailureToast(operationId, failureToast); RpcHelper::callRemoteSafely( *hub_, @@ -1773,6 +1789,7 @@ std::expected OperationQueue::addUploadOperation( auto individualOptions = resolveUploadOptions(transferOptions, resolvedTimeout); individualOptions.sourceMayVanish = sourceMayVanish; individualOptions.onSent = std::move(onSent); + individualOptions.failureToast = failureToast; auto bulk = std::make_unique( sftp.weak_from_this(), BulkUploadOperation::BulkUploadOperationOptions{ @@ -1789,11 +1806,13 @@ std::expected OperationQueue::addUploadOperation( std::make_unique(LocalScanOperation::ScanOperationOptions{ .progressCallback = makeScanProgressCallback("onLocalScanProgress", operationId), .localPath = localPath, + .failureToast = failureToast, }) ); auto& targetQueue = queueFor(mode); targetQueue.emplace_back(operationId, std::move(bulk)); + rememberFailureToast(operationId, failureToast); RpcHelper::callRemoteSafely( *hub_, @@ -1822,7 +1841,9 @@ std::expected OperationQueue::addUploadOperation( } else { - Log::error("Local path is neither a file nor a directory: {}.", static_cast(stat.type())); + Log::error( + failureToast, "Local path is neither a file nor a directory: {}.", static_cast(stat.type()) + ); return std::unexpected(Operation::Error{.type = Operation::ErrorType::OperationNotPossibleOnFileType}); } } @@ -1833,7 +1854,8 @@ std::size_t OperationQueue::addBulkUploadOperation( std::function const& operationIdFor, Ids::OperationId const& bulkCardId, bool sourceMayVanish, - UploadOperation::OnSent const& onSent + UploadOperation::OnSent const& onSent, + Log::Toast failureToast ) { // Assumed in strand. @@ -1860,11 +1882,14 @@ std::size_t OperationQueue::addBulkUploadOperation( unit.mode, sourceMayVanish, std::nullopt, - onSent + onSent, + std::nullopt, + failureToast ); if (!result) { Log::error( + failureToast, "Bulk upload: directory entry '{}' failed to queue: {}", entry.source.toUtf8(), result.error().toString() @@ -1901,6 +1926,7 @@ std::size_t OperationQueue::addBulkUploadOperation( uploadOptions.createMissingDirectories = true; uploadOptions.sourceMayVanish = sourceMayVanish; uploadOptions.onSent = onSent; + uploadOptions.failureToast = failureToast; auto bulk = std::make_unique( sftp.weak_from_this(), BulkUploadOperation::BulkUploadOperationOptions{ @@ -1914,6 +1940,7 @@ std::size_t OperationQueue::addBulkUploadOperation( ); bulk->setPrescannedFileList(std::move(files)); queueFor(unit.mode).emplace_back(bulkCardId, std::move(bulk)); + rememberFailureToast(bulkCardId, failureToast); RpcHelper::callRemoteSafely( *hub_, rpcName("onOperationAdded"), diff --git a/backend/source/backend/sftp/upload_operation.cpp b/backend/source/backend/sftp/upload_operation.cpp index bdbc0d66..947ee758 100644 --- a/backend/source/backend/sftp/upload_operation.cpp +++ b/backend/source/backend/sftp/upload_operation.cpp @@ -94,7 +94,7 @@ std::expected UploadOperati ); if (fut.wait_for(options_.futureTimeout) != std::future_status::ready) { - Log::error("UploadOperation: work umbrella timed out."); + Log::error(options_.failureToast, "UploadOperation: work umbrella timed out."); return enterErrorState({.type = ErrorType::FutureTimeout}); } return fut.get(); @@ -134,6 +134,7 @@ std::expected UploadOperati { Log::log( failureLevelOf(prepareResult.error()), + options_.failureToast, "UploadOperation: Failed to prepare operation: {}", prepareResult.error().toString() ); @@ -151,7 +152,11 @@ std::expected UploadOperati const auto result = handleSymlinkInStrand(*sftp); if (!result.has_value()) { - Log::error("UploadOperation: Failed to handle symlink: {}", result.error().toString()); + Log::error( + options_.failureToast, + "UploadOperation: Failed to handle symlink: {}", + result.error().toString() + ); return enterErrorState(result.error()); } Log::info("UploadOperation: Symlink handled, no file to upload."); @@ -165,7 +170,7 @@ std::expected UploadOperati auto stream = fileStream_.lock(); if (!stream) { - Log::error("UploadOperation: File stream expired."); + Log::error(options_.failureToast, "UploadOperation: File stream expired."); return enterErrorState({.type = ErrorType::FileStreamExpired}); } const auto contextResult = stream->writeAsyncInStrand( @@ -179,7 +184,11 @@ std::expected UploadOperati ); if (!contextResult.has_value()) { - Log::error("UploadOperation: Failed to start async read: {}", contextResult.error().toString()); + Log::error( + options_.failureToast, + "UploadOperation: Failed to start async read: {}", + contextResult.error().toString() + ); return enterErrorState( {.type = ErrorType::SftpError, .sftpError = contextResult.error()} ); @@ -195,7 +204,9 @@ std::expected UploadOperati const auto result = writeOnceInStrand(); if (!result.has_value()) { - Log::error("UploadOperation: Failed to write file: {}", result.error().toString()); + Log::error( + options_.failureToast, "UploadOperation: Failed to write file: {}", result.error().toString() + ); return enterErrorState(result.error()); } if (result.value()) @@ -243,6 +254,7 @@ std::expected UploadOperati { Log::log( failureLevelOf(finalizeResult.error()), + options_.failureToast, "UploadOperation: Failed to finalize operation: {}", finalizeResult.error().toString() ); @@ -277,7 +289,7 @@ std::expected UploadOperati return std::unexpected(Error{.type = ErrorType::CannotWorkCompletedOperation}); } } - Log::error("UploadOperation: Unknown operation state: {}", static_cast(state_)); + Log::error(options_.failureToast, "UploadOperation: Unknown operation state: {}", static_cast(state_)); return enterErrorState({.type = ErrorType::UnknownWorkState}); } @@ -289,7 +301,7 @@ UploadOperation::commitFileToBuffer(SecureShell::IFileStream::SignedSizeType byt ); if (!localFile_.good()) { - Log::error("UploadOperation write cycle stopped: localFile_.good() == false"); + Log::error(options_.failureToast, "UploadOperation write cycle stopped: localFile_.good() == false"); // Ends the transfer, the next work step fails with it. An error state entered here would not reach the owner. readFailure_ = localFileError(ErrorType::SourceFileNotGood, localFile_.lastError()); return -1; @@ -313,7 +325,7 @@ std::expected UploadOperation::handleSymlink() ); if (fut.wait_for(options_.futureTimeout) != std::future_status::ready) { - Log::error("UploadOperation: handleSymlink umbrella timed out."); + Log::error(options_.failureToast, "UploadOperation: handleSymlink umbrella timed out."); return std::unexpected(Error{.type = ErrorType::FutureTimeout}); } return fut.get(); @@ -332,6 +344,7 @@ std::expected UploadOperation::handleSymlinkInStra if (readError) { Log::error( + options_.failureToast, "UploadOperation: Cannot read the symlink '{}': {}.", options_.localPath.toUtf8(), Utility::errorMessage(readError) @@ -341,7 +354,11 @@ std::expected UploadOperation::handleSymlinkInStra const auto targetText = Paths::checkedNativeToUtf8(target); if (!targetText) { - Log::error("UploadOperation: The symlink '{}' points to a name that is not UTF-8.", options_.localPath.toUtf8()); + Log::error( + options_.failureToast, + "UploadOperation: The symlink '{}' points to a name that is not UTF-8.", + options_.localPath.toUtf8() + ); return std::unexpected(Error{.type = ErrorType::InvalidName, .extraInfo = targetText.error().toString()}); } @@ -358,7 +375,10 @@ std::expected UploadOperation::handleSymlinkInStra if (!result.has_value()) { Log::error( - "UploadOperation: Failed to create symlink at '{}': {}.", tempPath_.value(), result.error().message + options_.failureToast, + "UploadOperation: Failed to create symlink at '{}': {}.", + tempPath_.value(), + result.error().message ); return std::unexpected(Error{.type = ErrorType::SftpError, .sftpError = result.error()}); } @@ -370,7 +390,8 @@ std::expected UploadOperation::handleSymlinkInStra options_.mayOverwrite, Persistence::asideFileSuffix(options_.tempFileSuffix), SharedData::FileType::Symlink, - options_.comparedDestination + options_.comparedDestination, + options_.failureToast ); if (!moved.has_value()) { @@ -393,7 +414,7 @@ std::expected UploadOperation::writeOnce() ); if (fut.wait_for(options_.futureTimeout) != std::future_status::ready) { - Log::error("UploadOperation: writeOnce umbrella timed out."); + Log::error(options_.failureToast, "UploadOperation: writeOnce umbrella timed out."); return enterErrorState({.type = ErrorType::FutureTimeout}); } return fut.get(); @@ -403,20 +424,20 @@ std::expected UploadOperation::writeOnceInStrand() { if (state_ < OperationState::Prepared) { - Log::error("UploadOperation: Operation not prepared."); + Log::error(options_.failureToast, "UploadOperation: Operation not prepared."); return enterErrorState({.type = ErrorType::OperationNotPrepared}); } if (!localFile_.is_open()) { - Log::error("UploadOperation: File is not open."); + Log::error(options_.failureToast, "UploadOperation: File is not open."); return enterErrorState({.type = ErrorType::OpenFailure}); } auto stream = fileStream_.lock(); if (!stream) { - Log::error("UploadOperation: File stream expired."); + Log::error(options_.failureToast, "UploadOperation: File stream expired."); return enterErrorState({.type = ErrorType::FileStreamExpired}); } @@ -424,12 +445,12 @@ std::expected UploadOperation::writeOnceInStrand() const auto readCount = static_cast(localFile_.gcount()); if (readCount > leftToUpload_) { - Log::error("UploadOperation: Read more data than expected."); + Log::error(options_.failureToast, "UploadOperation: Read more data than expected."); return enterErrorState({.type = ErrorType::ImplementationError}); } if (readCount == 0 && leftToUpload_ > 0) { - Log::error("UploadOperation: The local file ended before its expected size was read."); + Log::error(options_.failureToast, "UploadOperation: The local file ended before its expected size was read."); return enterErrorState(localFileError(ErrorType::SourceFileNotGood, localFile_.lastError())); } leftToUpload_ -= readCount; @@ -437,7 +458,9 @@ std::expected UploadOperation::writeOnceInStrand() const auto result = stream->writeInStrand(std::string_view{buffer_.data(), readCount}); if (!result.has_value()) { - Log::error("UploadOperation: Failed to write to remote file: {}", result.error().message); + Log::error( + options_.failureToast, "UploadOperation: Failed to write to remote file: {}", result.error().message + ); return enterErrorState({.type = ErrorType::SftpError, .sftpError = result.error()}); } @@ -454,7 +477,7 @@ std::expected UploadOperation::openOrAdoptFileInSt const auto err = result.error(); if (err.sftpError != SSH_FX_NO_SUCH_FILE) { - Log::error("Failed to stat remote sftp file for continue: {}", err.message); + Log::error(options_.failureToast, "Failed to stat remote sftp file for continue: {}", err.message); return std::unexpected(Error{.type = ErrorType::SftpError, .sftpError = err}); } // else ok — target does not yet exist. @@ -506,7 +529,11 @@ std::expected UploadOperation::openOrAdoptFileInSt const auto openResult = sftp.openFileInStrand(tempPath_, SecureShell::SftpSession::OpenType::Write, perms); if (!openResult.has_value()) { - Log::error("Failed to open remote sftp file for continue: {}", openResult.error().message); + Log::error( + options_.failureToast, + "Failed to open remote sftp file for continue: {}", + openResult.error().message + ); return std::unexpected(Error{.type = ErrorType::SftpError, .sftpError = openResult.error()}); } fileStream_ = openResult.value(); @@ -515,14 +542,18 @@ std::expected UploadOperation::openOrAdoptFileInSt auto stream = fileStream_.lock(); if (!stream) { - Log::error("UploadOperation: File stream expired."); + Log::error(options_.failureToast, "UploadOperation: File stream expired."); return std::unexpected(Error{.type = ErrorType::FileStreamExpired}); } const auto seekResult = stream->seekInStrand(tempResult->size); if (!seekResult.has_value()) { - Log::error("Failed to seek remote sftp file for continue: {}", seekResult.error().message); + Log::error( + options_.failureToast, + "Failed to seek remote sftp file for continue: {}", + seekResult.error().message + ); return std::unexpected(Error{.type = ErrorType::SftpError, .sftpError = seekResult.error()}); } @@ -550,7 +581,7 @@ std::expected UploadOperation::openOrAdoptFileInSt ); if (!openResult.has_value()) { - Log::error("Failed to open remote sftp file for upload: {}", openResult.error().message); + Log::error(options_.failureToast, "Failed to open remote sftp file for upload: {}", openResult.error().message); return std::unexpected(Error{.type = ErrorType::SftpError, .sftpError = openResult.error()}); } @@ -603,7 +634,9 @@ UploadOperation::ensureRemoteDirectoryExistsInStrand(SecureShell::SftpSession& s if (statResult->type == SharedData::FileType::Directory) return {}; Log::error( - "UploadOperation: Cannot create parent '{}': path exists and is not a directory.", dir.value() + options_.failureToast, + "UploadOperation: Cannot create parent '{}': path exists and is not a directory.", + dir.value() ); return std::unexpected( Error{ @@ -630,7 +663,10 @@ UploadOperation::ensureRemoteDirectoryExistsInStrand(SecureShell::SftpSession& s if (!mkdirResult.has_value()) { Log::error( - "UploadOperation: Failed to create parent dir '{}': {}.", dir.value(), mkdirResult.error().message + options_.failureToast, + "UploadOperation: Failed to create parent dir '{}': {}.", + dir.value(), + mkdirResult.error().message ); return std::unexpected(Error{.type = ErrorType::SftpError, .sftpError = mkdirResult.error()}); } @@ -647,14 +683,16 @@ std::expected UploadOperation::openLocalSource() { if (options_.localPath.empty()) { - Log::error("UploadOperation: Invalid local path."); + Log::error(options_.failureToast, "UploadOperation: Invalid local path."); return std::unexpected(Error{.type = ErrorType::InvalidPath}); } std::error_code statusError{}; const auto linkStatus = std::filesystem::symlink_status(localNativePath_, statusError); if (linkStatus.type() == std::filesystem::file_type::not_found) { - Log::error("UploadOperation: Local file '{}' does not exist.", options_.localPath.toUtf8()); + Log::error( + options_.failureToast, "UploadOperation: Local file '{}' does not exist.", options_.localPath.toUtf8() + ); return std::unexpected( Error{.type = ErrorType::FileNotFound, .localProblem = SharedData::LocalFileProblem::NotFound} ); @@ -662,13 +700,20 @@ std::expected UploadOperation::openLocalSource() if (statusError) { Log::error( - "UploadOperation: Cannot inspect '{}': {}", options_.localPath.toUtf8(), Utility::errorMessage(statusError) + options_.failureToast, + "UploadOperation: Cannot inspect '{}': {}", + options_.localPath.toUtf8(), + Utility::errorMessage(statusError) ); return std::unexpected(localFileError(ErrorType::FileStatFailed, statusError)); } if (!std::filesystem::is_regular_file(linkStatus) && !std::filesystem::is_symlink(linkStatus)) { - Log::error("UploadOperation: Local path '{}' is not a regular file or symlink.", options_.localPath.toUtf8()); + Log::error( + options_.failureToast, + "UploadOperation: Local path '{}' is not a regular file or symlink.", + options_.localPath.toUtf8() + ); return std::unexpected(Error{.type = ErrorType::NotAFile}); } if (std::filesystem::is_symlink(linkStatus) && @@ -682,14 +727,20 @@ std::expected UploadOperation::openLocalSource() const auto status = std::filesystem::status(localNativePath_, statusError); if (status.type() == std::filesystem::file_type::not_found) { - Log::error("UploadOperation: Local file '{}' does not exist.", options_.localPath.toUtf8()); + Log::error( + options_.failureToast, "UploadOperation: Local file '{}' does not exist.", options_.localPath.toUtf8() + ); return std::unexpected( Error{.type = ErrorType::FileNotFound, .localProblem = SharedData::LocalFileProblem::NotFound} ); } if (statusError || !std::filesystem::is_regular_file(status)) { - Log::error("UploadOperation: Local path '{}' is not a regular file.", options_.localPath.toUtf8()); + Log::error( + options_.failureToast, + "UploadOperation: Local path '{}' is not a regular file.", + options_.localPath.toUtf8() + ); return std::unexpected( statusError ? localFileError(ErrorType::NotAFile, statusError) : Error{.type = ErrorType::NotAFile} ); @@ -701,6 +752,7 @@ std::expected UploadOperation::openLocalSource() if (!localFile_.is_open()) { Log::error( + options_.failureToast, "UploadOperation: Failed to open local file '{}': {}", options_.localPath.toUtf8(), Utility::errorMessage(localFile_.lastError()) @@ -713,7 +765,9 @@ std::expected UploadOperation::openLocalSource() localFile_.seekg(0, std::ios::beg); if (!localFile_.good()) { - Log::error("UploadOperation: Failed to seek in local file: {}", options_.localPath.toUtf8()); + Log::error( + options_.failureToast, "UploadOperation: Failed to seek in local file: {}", options_.localPath.toUtf8() + ); return std::unexpected(Error{.type = ErrorType::FileSeekFailure}); } return {}; @@ -723,12 +777,12 @@ std::expected UploadOperation::prepare() { if (options_.localPath.empty()) { - Log::error("UploadOperation: Invalid local path."); + Log::error(options_.failureToast, "UploadOperation: Invalid local path."); return enterErrorState({.type = ErrorType::InvalidPath}); } if (remotePath_.empty() || remotePath_.isRoot() || tempPath_.empty()) { - Log::error("UploadOperation: Invalid remote path '{}'.", options_.remotePath.value()); + Log::error(options_.failureToast, "UploadOperation: Invalid remote path '{}'.", options_.remotePath.value()); return enterErrorState({.type = ErrorType::InvalidPath, .extraInfo = options_.remotePath.value()}); } if (const auto opened = openLocalSource(); !opened) @@ -750,13 +804,13 @@ std::expected UploadOperation::prepare() ); if (umbrellaFut.wait_for(options_.futureTimeout) != std::future_status::ready) { - Log::error("UploadOperation: prepare umbrella timed out."); + Log::error(options_.failureToast, "UploadOperation: prepare umbrella timed out."); return enterErrorState({.type = ErrorType::FutureTimeout, .extraInfo = "Timeout preparing upload"}); } auto openResult = umbrellaFut.get(); if (!openResult.has_value()) { - Log::error("UploadOperation: Failed to open file."); + Log::error(options_.failureToast, "UploadOperation: Failed to open file."); return enterErrorState(std::move(openResult).error()); } @@ -775,14 +829,14 @@ std::expected UploadOperation::prepareInStrand(Sec auto stream = fileStream_.lock(); if (!stream) { - Log::error("UploadOperation: File stream expired after open."); + Log::error(options_.failureToast, "UploadOperation: File stream expired after open."); return std::unexpected(Error{.type = ErrorType::FileStreamExpired}); } buffer_ = sftp.bufferProvider().leaseForTransfer(totalSize_, stream->writeLengthLimit()); if (buffer_.empty()) { - Log::error("UploadOperation: No transfer buffer available from pool."); + Log::error(options_.failureToast, "UploadOperation: No transfer buffer available from pool."); return std::unexpected( Error{ .type = ErrorType::SftpError, @@ -877,7 +931,7 @@ std::expected UploadOperation::finalize() { if (state_ == OperationState::Running) { - Log::error("UploadOperation: Cannot finalize while reading."); + Log::error(options_.failureToast, "UploadOperation: Cannot finalize while reading."); return std::unexpected(Error{.type = ErrorType::CannotFinalizeDuringRead}); } @@ -899,7 +953,7 @@ std::expected UploadOperation::finalize() // so allow a bit more slack than the per-call timeout. if (fut.wait_for(options_.futureTimeout * 4) != std::future_status::ready) { - Log::error("UploadOperation: finalize umbrella timed out."); + Log::error(options_.failureToast, "UploadOperation: finalize umbrella timed out."); return std::unexpected(Error{.type = ErrorType::FutureTimeout}); } auto result = fut.get(); @@ -917,14 +971,14 @@ std::expected UploadOperation::finalizeInStrand(Se auto stream = fileStream_.lock(); if (!stream) { - Log::error("UploadOperation: File stream expired."); + Log::error(options_.failureToast, "UploadOperation: File stream expired."); return std::unexpected(Error{.type = ErrorType::FileStreamExpired}); } // Every byte must have been written before the temp file may replace the destination. if (leftToUpload_ != 0) { - Log::error("UploadOperation: {} bytes were not uploaded.", leftToUpload_); + Log::error(options_.failureToast, "UploadOperation: {} bytes were not uploaded.", leftToUpload_); return std::unexpected( Error{ .type = ErrorType::SizeMismatch, @@ -947,7 +1001,8 @@ std::expected UploadOperation::finalizeInStrand(Se options_.mayOverwrite, Persistence::asideFileSuffix(options_.tempFileSuffix), SharedData::FileType::Regular, - options_.comparedDestination + options_.comparedDestination, + options_.failureToast ); if (!moved.has_value()) { diff --git a/backend/test/backend/test_operation_queue.hpp b/backend/test/backend/test_operation_queue.hpp index d9fd15ae..b4e2b86c 100644 --- a/backend/test/backend/test_operation_queue.hpp +++ b/backend/test/backend/test_operation_queue.hpp @@ -8,6 +8,7 @@ #include "nui_env.hpp" #include "real_server_tests.hpp" +#include #include #include #include @@ -18,11 +19,15 @@ #include #include +#include #include #include +#include #include #include #include +#include +#include #include #include #include @@ -685,6 +690,132 @@ namespace Test } #ifndef _WIN32 + /** + * @brief The messages at error level or above logged since the logger kept @p stashedBefore messages for the + * frontend. + */ + inline std::vector errorsStashedSince(std::size_t stashedBefore) + { + const auto stashed = Log::Detail::logger.stashed(); + std::vector errors; + std::ranges::copy_if( + stashed | std::views::drop(stashedBefore), + std::back_inserter(errors), + [](Log::Logger::StashedMessage const& message) + { + return message.level >= Log::Level::Error; + } + ); + return errors; + } + + TEST_F(OperationQueueTests, AnUploadWhoseOwnerListsItsFailuresLogsThemWithoutAToast) + { + if (::geteuid() == 0) + GTEST_SKIP() << "root can read any file"; + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + const ErrorLogCapture errors; + + const auto file = localDirectory_.path() / "unreadable.txt"; + std::ofstream{file} << "unreadable"; + std::filesystem::permissions(file, std::filesystem::perms::none); + const auto stashedBefore = Log::Detail::logger.stashed().size(); + const auto added = runOnStrand( + [&]() + { + return queue->addUploadOperation( + *sftp, + Ids::generateOperationId(), + toLocal(file), + remote("/home/test/unreadable.txt"), + true, + false, + false, + false, + SharedData::OperationMode::Queued, + false, + std::nullopt, + {}, + std::nullopt, + Log::Toast::Skip + ); + } + ); + ASSERT_TRUE(added.has_value()) << added.error().toString(); + setPaused(false); + + workUntilCompletions(1); + const auto finished = completions(); + ASSERT_EQ(finished.size(), 1u); + EXPECT_EQ(finished.front().reason, SharedData::OperationCompletionReason::Failed); + // Still logged, only without the toast. + EXPECT_GT(errors.mentioning("unreadable.txt"), 0u); + const auto logged = errorsStashedSince(stashedBefore); + EXPECT_FALSE(logged.empty()); + for (auto const& message : logged) + EXPECT_EQ(message.toast, Log::Toast::Skip) << message.message; + } + + TEST_F(OperationQueueTests, ABulkUploadWhoseOwnerListsItsFailuresLogsThemWithoutAToast) + { + if (::geteuid() == 0) + GTEST_SKIP() << "root can read any file"; + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + const ErrorLogCapture errors; + + const auto localPath = localDirectory_.path() / "partlyUnreadable"; + std::filesystem::create_directories(localPath); + std::ofstream{localPath / "kept.txt"} << "kept"; + std::ofstream{localPath / "unreadable.txt"} << "unreadable"; + std::filesystem::permissions(localPath / "unreadable.txt", std::filesystem::perms::none); + const SharedData::BulkUploadRequest request{ + .entries = + { + {.source = toLocal(localPath / "kept.txt"), + .destination = remote("/home/test/partlyUnreadable/kept.txt"), + .sizeBytes = 4}, + {.source = toLocal(localPath / "unreadable.txt"), + .destination = remote("/home/test/partlyUnreadable/unreadable.txt"), + .sizeBytes = 10}, + }, + .allowOverwrite = true, + }; + const auto stashedBefore = Log::Detail::logger.stashed().size(); + runOnStrand( + [&]() + { + return queue->addBulkUploadOperation( + *sftp, + request, + [](std::size_t) + { + return Ids::generateOperationId(); + }, + Ids::generateOperationId(), + /*sourceMayVanish=*/true, + {}, + Log::Toast::Skip + ); + } + ); + setPaused(false); + + workUntilCompletions(1); + const auto finished = completions(); + ASSERT_EQ(finished.size(), 1u); + EXPECT_EQ(finished.front().failedEntries.size(), 1u); + EXPECT_EQ(remoteNames(*sftp, "/home/test/partlyUnreadable"), std::set{"kept.txt"}); + EXPECT_GT(errors.mentioning("unreadable.txt"), 0u); + const auto logged = errorsStashedSince(stashedBefore); + EXPECT_FALSE(logged.empty()); + for (auto const& message : logged) + EXPECT_EQ(message.toast, Log::Toast::Skip) << message.message; + } + TEST_F(OperationQueueTests, DirectoryUploadReportsAnUnreadableSubdirectory) { if (::geteuid() == 0) From 36b493c2a6aec3ad968426e1509c09bec64a624c Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 20:25:06 +0200 Subject: [PATCH 097/114] Logged failed reconnect attempts without a toast unless the cycle gives up - ProtoSession, FrontendSessionManager::open and TerminalEngine::open take a failureToast - SessionManager::connect reads showFailureToast and logs its connect errors with it - SessionArea skips it for every attempt but the last one that maxReconnectAttempts allows --- backend/include/backend/session_manager.hpp | 6 +++ backend/source/backend/session_manager.cpp | 30 ++++++++++--- frontend/include/frontend/proto_session.hpp | 6 +++ .../frontend/terminal/executing_engine.hpp | 2 +- .../terminal/frontend_session_manager.hpp | 3 +- .../include/frontend/terminal/ssh_engine.hpp | 6 ++- .../frontend/terminal/terminal_engine.hpp | 7 ++- frontend/source/frontend/proto_session.cpp | 5 ++- frontend/source/frontend/session_area.cpp | 44 ++++++++++++++----- .../frontend/terminal/executing_engine.cpp | 2 +- .../terminal/frontend_session_manager.cpp | 13 +++--- .../source/frontend/terminal/ssh_engine.cpp | 7 +-- 12 files changed, 99 insertions(+), 32 deletions(-) diff --git a/backend/include/backend/session_manager.hpp b/backend/include/backend/session_manager.hpp index ebedcecd..ebc1a282 100644 --- a/backend/include/backend/session_manager.hpp +++ b/backend/include/backend/session_manager.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -64,8 +65,13 @@ class SessionManager void addPasswordProvider(int priority, PasswordProvider* provider); void joinSessionAdder(); + /** + * @brief Connects a new session with @p sessionOptions, @p onComplete hears its id or why it failed. + * @param failureToast Whether the error logged when it fails is shown as a toast. + */ void addSession( Persistence::SshSessionOptions const& sessionOptions, + Log::Toast failureToast, std::function const&, std::optional const& errorMessage)> onComplete ); diff --git a/backend/source/backend/session_manager.cpp b/backend/source/backend/session_manager.cpp index 763d4dde..f3ecf0da 100644 --- a/backend/source/backend/session_manager.cpp +++ b/backend/source/backend/session_manager.cpp @@ -55,6 +55,18 @@ namespace } ); } + + /** + * @brief Whether the errors of a connect that fails are shown as toasts, a reconnect attempt that is retried + * asks for none. + */ + Log::Toast failureToastOf(nlohmann::json const& parameters) + { + const auto found = parameters.find("showFailureToast"); + if (found != parameters.end() && found->is_boolean() && !found->get()) + return Log::Toast::Skip; + return Log::Toast::Show; + } } int askPassDefault(char const* prompt, char* buf, std::size_t length, int, int, void* userdata) @@ -215,11 +227,12 @@ void SessionManager::addPasswordProvider(int priority, PasswordProvider* provide void SessionManager::addSession( Persistence::SshSessionOptions const& sessionOptions, + Log::Toast failureToast, std::function const&, std::optional const& errorMessage)> onComplete ) { within_strand_do( - [this, sessionOptions, onComplete = std::move(onComplete)]() + [this, sessionOptions, failureToast, onComplete = std::move(onComplete)]() { const auto sessionId = Ids::SessionId{Ids::generateId()}; @@ -299,7 +312,7 @@ void SessionManager::addSession( } else { - Log::error("Failed to create session: {}", maybeSshSession.error()); + Log::error(failureToast, "Failed to create session: {}", maybeSshSession.error()); onComplete(std::nullopt, maybeSshSession.error()); } } @@ -365,14 +378,17 @@ void SessionManager::registerRpcSessionConnect() return; } + const auto failureToast = failureToastOf(parameters); auto onComplete = rpcSafe( std::move(reply), - [](auto const& reply, auto const& maybeId, auto const& maybeErrorMessage) + [failureToast](auto const& reply, auto const& maybeId, auto const& maybeErrorMessage) { if (!maybeId) { Log::error( - "Failed to connect to ssh server: {}", maybeErrorMessage.value_or("unknown error") + failureToast, + "Failed to connect to ssh server: {}", + maybeErrorMessage.value_or("unknown error") ); return reply( {{"error", @@ -385,7 +401,11 @@ void SessionManager::registerRpcSessionConnect() } ); - addSession(parameters["engineOptions"].get(), std::move(onComplete)); + addSession( + parameters["engineOptions"].get(), + failureToast, + std::move(onComplete) + ); } ); } diff --git a/frontend/include/frontend/proto_session.hpp b/frontend/include/frontend/proto_session.hpp index 057b86d5..77a0c353 100644 --- a/frontend/include/frontend/proto_session.hpp +++ b/frontend/include/frontend/proto_session.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -52,6 +53,11 @@ class ProtoSession /// can be destroyed freely; its FSM has already begun disposal per /// FrontendSessionManager::open's contract. std::function onFailed{}; + /** + * @brief Whether the errors logged when the probe fails are shown as toasts. A reconnect attempt that is + * retried skips them, the lost-connection overlay shows the attempts. + */ + Log::Toast failureToast{Log::Toast::Show}; }; explicit ProtoSession(Params params); diff --git a/frontend/include/frontend/terminal/executing_engine.hpp b/frontend/include/frontend/terminal/executing_engine.hpp index ca0e9ec5..8d5197d2 100644 --- a/frontend/include/frontend/terminal/executing_engine.hpp +++ b/frontend/include/frontend/terminal/executing_engine.hpp @@ -51,7 +51,7 @@ class ExecutingTerminalEngine : public TerminalEngine ROAR_PIMPL_SPECIAL_FUNCTIONS(ExecutingTerminalEngine); /** @brief Signals success immediately — local processes need no prior connection. */ - void open(std::function onOpen) override; + void open(std::function onOpen, Log::Toast failureToast) override; void dispose(std::function onDisposeComplete) override; /** @brief Returns the engine-level identifier (not a process UUID). */ diff --git a/frontend/include/frontend/terminal/frontend_session_manager.hpp b/frontend/include/frontend/terminal/frontend_session_manager.hpp index 3da9af3b..9a36a5c0 100644 --- a/frontend/include/frontend/terminal/frontend_session_manager.hpp +++ b/frontend/include/frontend/terminal/frontend_session_manager.hpp @@ -68,8 +68,9 @@ class FrontendSessionManager * disposing itself and calls @p onOpen(false, reason). * * @param onOpen Completion callback — (success, info). + * @param failureToast Whether the errors logged when it fails are shown as toasts, see TerminalEngine::open. */ - void open(std::function onOpen); + void open(std::function onOpen, Log::Toast failureToast = Log::Toast::Show); /** * @brief Creates a new primary-engine channel and binds an xterm.js terminal to it. diff --git a/frontend/include/frontend/terminal/ssh_engine.hpp b/frontend/include/frontend/terminal/ssh_engine.hpp index fa1ed1db..9cc5b4de 100644 --- a/frontend/include/frontend/terminal/ssh_engine.hpp +++ b/frontend/include/frontend/terminal/ssh_engine.hpp @@ -24,7 +24,11 @@ class SshTerminalEngine : public TerminalEngine SshTerminalEngine(Settings settings); ROAR_PIMPL_SPECIAL_FUNCTIONS(SshTerminalEngine); - void open(std::function onOpen) override; + /** + * @brief Connects to the server, see TerminalEngine::open. The backend logs its connect errors with + * @p failureToast too. + */ + void open(std::function onOpen, Log::Toast failureToast) override; /** * @brief Spawns an SSH shell channel. @p options is unused — SSH reads diff --git a/frontend/include/frontend/terminal/terminal_engine.hpp b/frontend/include/frontend/terminal/terminal_engine.hpp index 725ed94e..bb414406 100644 --- a/frontend/include/frontend/terminal/terminal_engine.hpp +++ b/frontend/include/frontend/terminal/terminal_engine.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -10,7 +11,11 @@ class TerminalEngine { public: - virtual void open(std::function onOpen) = 0; + /** + * @brief Opens the engine, @p onOpen hears whether it succeeded. + * @param failureToast Whether the errors logged when it fails are shown as toasts. + */ + virtual void open(std::function onOpen, Log::Toast failureToast) = 0; virtual std::string engineName() const = 0; virtual void dispose(std::function onDisposeComplete) = 0; diff --git a/frontend/source/frontend/proto_session.cpp b/frontend/source/frontend/proto_session.cpp index 5ec2b1fa..9bfc5368 100644 --- a/frontend/source/frontend/proto_session.cpp +++ b/frontend/source/frontend/proto_session.cpp @@ -14,6 +14,7 @@ struct ProtoSession::Implementation std::optional resumeFromSnapshot; std::function onReady; std::function onFailed; + Log::Toast failureToast; std::unique_ptr frontendSessionManager; std::string engineNameCache; @@ -25,6 +26,7 @@ struct ProtoSession::Implementation , resumeFromSnapshot{std::move(params.resumeFromSnapshot)} , onReady{std::move(params.onReady)} , onFailed{std::move(params.onFailed)} + , failureToast{params.failureToast} {} }; @@ -88,7 +90,8 @@ void ProtoSession::start() Log::warn("ProtoSession: transport open failed: {}", info); if (impl_->onFailed) impl_->onFailed(this, info); - } + }, + impl_->failureToast ); } diff --git a/frontend/source/frontend/session_area.cpp b/frontend/source/frontend/session_area.cpp index f8cc116f..6ee72a1a 100644 --- a/frontend/source/frontend/session_area.cpp +++ b/frontend/source/frontend/session_area.cpp @@ -55,6 +55,28 @@ struct ReconnectState std::unique_ptr candidate; }; +namespace +{ + /** + * @brief The most reconnect attempts @p options allow, -1 for no limit. + */ + int maximumReconnectAttemptsOf(Persistence::SessionOptions const& options) + { + if (auto const* ssh = std::get_if(&options.engine)) + return ssh->maxReconnectAttempts; + return -1; + } + + /** + * @brief Whether the reconnect cycle of @p state gives up when its current attempt fails. + */ + bool isFinalAttempt(ReconnectState const& state) + { + const auto maximumAttempts = maximumReconnectAttemptsOf(state.engineOptions); + return maximumAttempts >= 0 && state.attempt >= maximumAttempts; + } +} + struct SessionArea::Implementation { Persistence::StateHolder* stateHolder; @@ -612,6 +634,8 @@ void SessionArea::replaceAtTabId(int tabId, SessionSnapshot snapshot, int attemp protoParams.onFailed = [this, tabId](ProtoSession*, std::string const& info) { onProtoFailed(tabId, info); }; + // A failure that is retried shows in the lost-connection overlay, the last one also as a toast. + protoParams.failureToast = isFinalAttempt(reconnectState) ? Log::Toast::Show : Log::Toast::Skip; reconnectState.candidate = std::make_unique(std::move(protoParams)); reconnectState.candidate->start(); }, @@ -739,26 +763,22 @@ void SessionArea::scheduleReconnectRetry(int tabId) } auto& reconnectState = rcIter->second; - // Cap attempts against the session's max setting. -1 means unlimited. - int maxAttempts = -1; - int maxBackoffMs = 16000; - if (auto const* ssh = std::get_if(&reconnectState.engineOptions.engine)) - { - maxAttempts = ssh->maxReconnectAttempts; - maxBackoffMs = ssh->maxReconnectBackoffMs; - } - - if (maxAttempts >= 0 && reconnectState.attempt >= maxAttempts) + // Cap attempts against the session's max setting. + if (isFinalAttempt(reconnectState)) { Log::warn( - "scheduleReconnectRetry: reached max attempts ({}) for tabId {} — giving up", - maxAttempts, + "scheduleReconnectRetry: reached max attempts ({}) for tabId {}, giving up", + maximumReconnectAttemptsOf(reconnectState.engineOptions), tabId ); abortReconnectCycle(tabId); return; } + int maxBackoffMs = 16000; + if (auto const* ssh = std::get_if(&reconnectState.engineOptions.engine)) + maxBackoffMs = ssh->maxReconnectBackoffMs; + // Exponential backoff: 1s → 2s → 4s → 8s → 16s (capped at maxBackoffMs). const int clampedAttempt = std::min(reconnectState.attempt, 30); // prevent shift overflow const int delayMs = std::min(1000 << (clampedAttempt - 1), maxBackoffMs); diff --git a/frontend/source/frontend/terminal/executing_engine.cpp b/frontend/source/frontend/terminal/executing_engine.cpp index 9a5852ab..adf05f5f 100644 --- a/frontend/source/frontend/terminal/executing_engine.cpp +++ b/frontend/source/frontend/terminal/executing_engine.cpp @@ -43,7 +43,7 @@ ExecutingTerminalEngine::~ExecutingTerminalEngine() ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_DTOR(ExecutingTerminalEngine); -void ExecutingTerminalEngine::open(std::function onOpen) +void ExecutingTerminalEngine::open(std::function onOpen, Log::Toast) { // Local processes need no prior connection — signal success immediately. onOpen(true, impl_->engineId); diff --git a/frontend/source/frontend/terminal/frontend_session_manager.cpp b/frontend/source/frontend/terminal/frontend_session_manager.cpp index da4e0d63..24ca9200 100644 --- a/frontend/source/frontend/terminal/frontend_session_manager.cpp +++ b/frontend/source/frontend/terminal/frontend_session_manager.cpp @@ -123,7 +123,7 @@ FrontendSessionManager::FrontendSessionManager( impl_->auxLocalShellEngine = std::make_unique( ExecutingTerminalEngine::Settings{.onProcessChange = {}} ); - impl_->auxLocalShellEngine->open([](bool, std::string const&) {}); + impl_->auxLocalShellEngine->open([](bool, std::string const&) {}, Log::Toast::Show); } } @@ -378,7 +378,7 @@ void FrontendSessionManager::createLocalShellChannel( impl_->auxLocalShellEngine = std::make_unique( ExecutingTerminalEngine::Settings{.onProcessChange = onProcessChange} ); - impl_->auxLocalShellEngine->open([](bool, std::string const&) {}); + impl_->auxLocalShellEngine->open([](bool, std::string const&) {}, Log::Toast::Show); } else { @@ -670,18 +670,18 @@ bool FrontendSessionManager::sendToLastInteracted(std::string const& data, bool return true; } -void FrontendSessionManager::open(std::function onOpen) +void FrontendSessionManager::open(std::function onOpen, Log::Toast failureToast) { if (guardDisposal()) return; Log::info("Opening session"); impl_->primaryEngine->open( - [this, onOpen = std::move(onOpen)](bool success, std::string const& info) + [this, onOpen = std::move(onOpen), failureToast](bool success, std::string const& info) { if (!success) { - Log::error("Failed to open session: '{}'", info); + Log::error(failureToast, "Failed to open session: '{}'", info); dispose( [onOpen, info]() { @@ -691,7 +691,8 @@ void FrontendSessionManager::open(std::function return; } onOpen(true, info); - } + }, + failureToast ); } diff --git a/frontend/source/frontend/terminal/ssh_engine.cpp b/frontend/source/frontend/terminal/ssh_engine.cpp index ca4e360d..1229b47f 100644 --- a/frontend/source/frontend/terminal/ssh_engine.cpp +++ b/frontend/source/frontend/terminal/ssh_engine.cpp @@ -47,7 +47,7 @@ SshTerminalEngine::~SshTerminalEngine() ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_DTOR(SshTerminalEngine); -void SshTerminalEngine::open(std::function onOpen) +void SshTerminalEngine::open(std::function onOpen, Log::Toast failureToast) { if (impl_->blockedByDestruction) { @@ -57,14 +57,15 @@ void SshTerminalEngine::open(std::function onOpe Nui::val obj = Nui::val::object(); obj.set("engineOptions", asVal(std::get(impl_->settings.sessionOptions.engine))); + obj.set("showFailureToast", failureToast == Log::Toast::Show); Nui::RpcClient::callWithBackChannel( "SessionManager::connect", - [this, onOpen = std::move(onOpen)](Nui::val val) + [this, onOpen = std::move(onOpen), failureToast](Nui::val val) { if (!val.hasOwnProperty("id")) { - Log::error("SessionManager::connect callback did not return an id"); + Log::error(failureToast, "SessionManager::connect callback did not return an id"); std::string error = ""; if (val.hasOwnProperty("error")) error = val["error"].as(); From b9a3ddc86c1d9d1b15ba46c24deddbd24e2c107a Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 20:35:11 +0200 Subject: [PATCH 098/114] Gave local scan errors their local cause, as for unlistable folders --- .../source/backend/sftp/local_scan_operation.cpp | 13 +++++++++++-- backend/test/backend/test_local_scan_operation.hpp | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/source/backend/sftp/local_scan_operation.cpp b/backend/source/backend/sftp/local_scan_operation.cpp index 7d2495e1..5788ad67 100644 --- a/backend/source/backend/sftp/local_scan_operation.cpp +++ b/backend/source/backend/sftp/local_scan_operation.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -98,7 +99,11 @@ LocalScanOperation::scanner(Paths::RelativePath const& relative) Utility::errorMessage(error) ); return enterErrorState>( - {.type = ErrorType::FilesystemError, .extraInfo = Utility::errorMessage(error)} + { + .type = ErrorType::FilesystemError, + .extraInfo = Utility::errorMessage(error), + .localProblem = localFileProblemOf(error), + } ); }; std::error_code statusError{}; @@ -241,7 +246,11 @@ LocalScanOperation::listingFailed( Utility::errorMessage(errorCode) ); return enterErrorState>( - {.type = ErrorType::FilesystemError, .extraInfo = Utility::errorMessage(errorCode)} + { + .type = ErrorType::FilesystemError, + .extraInfo = Utility::errorMessage(errorCode), + .localProblem = localFileProblemOf(errorCode), + } ); } diff --git a/backend/test/backend/test_local_scan_operation.hpp b/backend/test/backend/test_local_scan_operation.hpp index cfed736e..9dbb9c8b 100644 --- a/backend/test/backend/test_local_scan_operation.hpp +++ b/backend/test/backend/test_local_scan_operation.hpp @@ -540,7 +540,8 @@ namespace Test result.value() != LocalScanOperation::WorkStatus::Complete; ++iteration) result = operation.work(); - EXPECT_FALSE(result.has_value()); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().localProblem, SharedData::LocalFileProblem::PermissionDenied); } TEST_F(LocalScanOperationTests, BuildTreeKeepsFifoAsExcludedPlaceholder) From 2984c118eff211665e62ec6fbfd82d8c74ea28cc Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 21:33:16 +0200 Subject: [PATCH 099/114] Hid Open Local Item on watches whose item moved away or whose copy is gone --- .../file_tracking/watch_header.cpp | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp index 864bb26b..cdebc643 100644 --- a/frontend/source/frontend/session_components/file_tracking/watch_header.cpp +++ b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp @@ -67,12 +67,25 @@ namespace FileTracking }); } + /** + * @brief Whether the watched item left its local path when @p stop ended the watch: it moved onto another + * watch's item, or the copy was deleted or renamed away. + */ + bool localItemLeft(SharedData::WatchStop const& stop) + { + return stop.reason == SharedData::WatchStopReason::Overlap || + stop.reason == SharedData::WatchStopReason::CopyGone; + } + /** * @brief Open Local Item, Reveal on Server and Stop for a running watch, Open Local Item and Remove for a - * stopped one. + * stopped one, only Remove when the item left its local path. */ - Nui::ElementRenderer - headerButtons(TrackingActions const* actions, std::shared_ptr const& item, bool stopped) + Nui::ElementRenderer headerButtons( + TrackingActions const* actions, + std::shared_ptr const& item, + std::optional const& stop + ) { using namespace Nui::Elements; using namespace Nui::Attributes; @@ -89,11 +102,11 @@ namespace FileTracking } ); - if (stopped) + if (stop) { // clang-format off return div{class_ = "file-tracking-detail-buttons"}( - std::move(openLocal), + localItemLeft(*stop) ? Nui::nil() : std::move(openLocal), actionButton( language->get("fileTracking", "removeButton"), "remove", @@ -251,7 +264,7 @@ namespace FileTracking Nui::observe(item->stop), [actionsPointer, item]() -> Nui::ElementRenderer { - return headerButtons(actionsPointer, item, item->stop.value().has_value()); + return headerButtons(actionsPointer, item, item->stop.value()); } ) ), From 4c5314964c376753d396df8dac7d11ea7d0aba36 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 21:33:16 +0200 Subject: [PATCH 100/114] Updated 5cript-nui-components to the merged file tracking components --- dependencies/5cript-nui-components | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/5cript-nui-components b/dependencies/5cript-nui-components index 5a315bd8..6896d19a 160000 --- a/dependencies/5cript-nui-components +++ b/dependencies/5cript-nui-components @@ -1 +1 @@ -Subproject commit 5a315bd8e5e096e89e8e01ac5b77dc6b5cfb4da8 +Subproject commit 6896d19af41fb0fd84aa0f437054a708ffb2da18 From 353eb0ac0c79a5601a2ab9a6be3df4f337192773 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 21:57:11 +0200 Subject: [PATCH 101/114] Added the shared types of batched conflict and failure removals --- .../file_tracking/removed_problems.hpp | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 shared_data/include/shared_data/file_tracking/removed_problems.hpp diff --git a/shared_data/include/shared_data/file_tracking/removed_problems.hpp b/shared_data/include/shared_data/file_tracking/removed_problems.hpp new file mode 100644 index 00000000..af464ec0 --- /dev/null +++ b/shared_data/include/shared_data/file_tracking/removed_problems.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +#include + +namespace SharedData +{ + /** + * @brief Conflicts that left the list together: answered, or dropped with their watch. + */ + struct RemovedConflicts + { + std::vector conflictIds{}; + }; + BOOST_DESCRIBE_STRUCT(RemovedConflicts, (), (conflictIds)) + + /** + * @brief Failed uploads that left the list together: retried, dismissed, uploaded since, or dropped with their + * watch. + */ + struct RemovedFailures + { + std::vector failureIds{}; + }; + BOOST_DESCRIBE_STRUCT(RemovedFailures, (), (failureIds)) +} From c5a61e4e7025ed50357dc7d464818f63bf8b38e7 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 22:03:25 +0200 Subject: [PATCH 102/114] Sent conflict and failure removals as one message per action - dropProblems hides what removeWatch returns instead of copying the lists first - Answer All, Retry All, Dismiss All, a stop and a directory upload's cleared failures each send one message --- .../file_tracking/frontend_tracker_view.hpp | 9 +- .../backend/file_tracking/tracker_view.hpp | 10 +- .../file_tracking/frontend_tracker_view.cpp | 8 +- .../source/backend/file_tracking/tracker.cpp | 115 ++++++++++++------ backend/test/backend/test_tracker.hpp | 43 +++++-- 5 files changed, 122 insertions(+), 63 deletions(-) diff --git a/backend/include/backend/file_tracking/frontend_tracker_view.hpp b/backend/include/backend/file_tracking/frontend_tracker_view.hpp index 06e2d578..caba360b 100644 --- a/backend/include/backend/file_tracking/frontend_tracker_view.hpp +++ b/backend/include/backend/file_tracking/frontend_tracker_view.hpp @@ -17,8 +17,9 @@ namespace FileTracking * @brief Shows a Tracker in the file tracking panel of its session, which listens to * @c "FileTracker::::": onEntry (SharedData::TrackedEntry), onEntryRemoved (watch id), * onEntriesEnded (SharedData::EndedWatches), onConflict (SharedData::WatchConflict), - * onConflictRemoved (conflict id), onFailure (SharedData::UploadFailure), onFailureRemoved (failure id), - * onWatchProgress (SharedData::WatchProgress) and onOrphanedCopies (SharedData::OrphanedCopies). + * onConflictsRemoved (SharedData::RemovedConflicts), onFailure (SharedData::UploadFailure), + * onFailuresRemoved (SharedData::RemovedFailures), onWatchProgress (SharedData::WatchProgress) and + * onOrphanedCopies (SharedData::OrphanedCopies). */ class FrontendTrackerView : public TrackerView { @@ -29,9 +30,9 @@ namespace FileTracking void removeEntry(Ids::WatchId const& watchId) override; void showEnded(SharedData::EndedWatches const& ended) override; void showConflict(SharedData::WatchConflict const& conflict) override; - void removeConflict(Ids::ConflictId const& conflictId) override; + void removeConflicts(SharedData::RemovedConflicts const& removed) override; void showFailure(SharedData::UploadFailure const& failure) override; - void removeFailure(Ids::FailureId const& failureId) override; + void removeFailures(SharedData::RemovedFailures const& removed) override; void showWatchProgress(SharedData::WatchProgress const& progress) override; void showOrphanedCopies(SharedData::OrphanedCopies const& copies) override; diff --git a/backend/include/backend/file_tracking/tracker_view.hpp b/backend/include/backend/file_tracking/tracker_view.hpp index 4e3575f4..4105d969 100644 --- a/backend/include/backend/file_tracking/tracker_view.hpp +++ b/backend/include/backend/file_tracking/tracker_view.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -43,9 +44,9 @@ namespace FileTracking virtual void showConflict(SharedData::WatchConflict const& conflict) = 0; /** - * @brief The conflict @p conflictId was answered or dropped with its watch. + * @brief The conflicts of @p removed were answered or dropped with their watch, all in one message. */ - virtual void removeConflict(Ids::ConflictId const& conflictId) = 0; + virtual void removeConflicts(SharedData::RemovedConflicts const& removed) = 0; /** * @brief Shows @p failure, new or updated. @@ -53,9 +54,10 @@ namespace FileTracking virtual void showFailure(SharedData::UploadFailure const& failure) = 0; /** - * @brief The failure @p failureId was retried, dismissed, went through or was dropped with its watch. + * @brief The failures of @p removed were retried, dismissed, went through or were dropped with their watch, all + * in one message. */ - virtual void removeFailure(Ids::FailureId const& failureId) = 0; + virtual void removeFailures(SharedData::RemovedFailures const& removed) = 0; /** * @brief Shows how far the watch of @p progress is, at most five times a second per watch. diff --git a/backend/source/backend/file_tracking/frontend_tracker_view.cpp b/backend/source/backend/file_tracking/frontend_tracker_view.cpp index 5cb39b95..68b7b934 100644 --- a/backend/source/backend/file_tracking/frontend_tracker_view.cpp +++ b/backend/source/backend/file_tracking/frontend_tracker_view.cpp @@ -33,9 +33,9 @@ namespace FileTracking RpcHelper::callRemoteSafely(*hub_, eventName("onConflict"), conflict); } - void FrontendTrackerView::removeConflict(Ids::ConflictId const& conflictId) + void FrontendTrackerView::removeConflicts(SharedData::RemovedConflicts const& removed) { - RpcHelper::callRemoteSafely(*hub_, eventName("onConflictRemoved"), conflictId.value()); + RpcHelper::callRemoteSafely(*hub_, eventName("onConflictsRemoved"), removed); } void FrontendTrackerView::showFailure(SharedData::UploadFailure const& failure) @@ -43,9 +43,9 @@ namespace FileTracking RpcHelper::callRemoteSafely(*hub_, eventName("onFailure"), failure); } - void FrontendTrackerView::removeFailure(Ids::FailureId const& failureId) + void FrontendTrackerView::removeFailures(SharedData::RemovedFailures const& removed) { - RpcHelper::callRemoteSafely(*hub_, eventName("onFailureRemoved"), failureId.value()); + RpcHelper::callRemoteSafely(*hub_, eventName("onFailuresRemoved"), removed); } void FrontendTrackerView::showWatchProgress(SharedData::WatchProgress const& progress) diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 6e754809..03aaf834 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -439,6 +441,38 @@ namespace FileTracking } ); } + + /** + * @brief The conflicts of @p listed, as the view takes them off in one message. + */ + template + SharedData::RemovedConflicts removedConflictsOf(std::vector const& listed) + { + SharedData::RemovedConflicts removed; + removed.conflictIds.reserve(listed.size()); + std::ranges::transform( + listed, + std::back_inserter(removed.conflictIds), + [](ListedT const& conflict) + { + return conflict.conflict.conflictId; + } + ); + return removed; + } + + /** + * @brief @p failures, as the view takes them off in one message. + */ + SharedData::RemovedFailures removedFailuresOf(std::vector const& failures) + { + SharedData::RemovedFailures removed; + removed.failureIds.reserve(failures.size()); + std::ranges::transform( + failures, std::back_inserter(removed.failureIds), &SharedData::UploadFailure::failureId + ); + return removed; + } } struct Tracker::Implementation : public std::enable_shared_from_this @@ -476,15 +510,6 @@ namespace FileTracking std::uint64_t claimId_; }; - /** - * @brief The ids of the conflicts and failures the view shows of watches. - */ - struct ShownProblems - { - std::vector conflicts{}; - std::vector failures{}; - }; - using OnOpened = std::function const&)>; boost::asio::strand strand; @@ -1128,29 +1153,29 @@ namespace FileTracking */ void dropProblems(Ids::WatchId const& watchId) { - ShownProblems shown; - collectShownProblems(watchId, shown); const auto dropped = conflicts.removeWatch(watchId); - failures.removeWatch(watchId); - hideProblems(shown); + hideConflicts(removedConflictsOf(dropped)); + hideFailures(removedFailuresOf(failures.removeWatch(watchId))); for (auto const& listed : dropped) leaveOrder(listed.context); } - void collectShownProblems(Ids::WatchId const& watchId, ShownProblems& shown) const + /** + * @brief Takes the conflicts of @p removed off the view in one message, none for an empty list. + */ + void hideConflicts(SharedData::RemovedConflicts const& removed) { - for (auto const& conflict : conflicts.conflictsOf(watchId)) - shown.conflicts.push_back(conflict.conflictId); - for (auto const& failure : failures.failuresOf(watchId)) - shown.failures.push_back(failure.failureId); + if (!removed.conflictIds.empty()) + view->removeConflicts(removed); } - void hideProblems(ShownProblems const& shown) + /** + * @brief Takes the failures of @p removed off the view in one message, none for an empty list. + */ + void hideFailures(SharedData::RemovedFailures const& removed) { - for (auto const& conflictId : shown.conflicts) - view->removeConflict(conflictId); - for (auto const& failureId : shown.failures) - view->removeFailure(failureId); + if (!removed.failureIds.empty()) + view->removeFailures(removed); } void setOption(Ids::WatchId const& watchId, SharedData::WatchOption option, bool value) @@ -2207,8 +2232,8 @@ namespace FileTracking return; } const auto watchId = listed->conflict.watchId; + hideConflicts({.conflictIds = {conflictId}}); resolveConflict(std::move(listed->context), resolution); - view->removeConflict(conflictId); progressChanged(watchId); } @@ -2226,11 +2251,9 @@ namespace FileTracking watchId.value(), Utility::enumToString(resolution) ); + hideConflicts(removedConflictsOf(listed)); for (auto& conflict : listed) - { resolveConflict(std::move(conflict.context), resolution); - view->removeConflict(conflict.conflict.conflictId); - } progressChanged(watchId); } @@ -2266,7 +2289,7 @@ namespace FileTracking Log::warn("FileTracking: there is no failure {} to retry", failureId.value()); return; } - view->removeFailure(failureId); + hideFailures({.failureIds = {failureId}}); retryUpload(*retried); } @@ -2307,25 +2330,38 @@ namespace FileTracking Log::warn("FileTracking: there is no failure {} to dismiss", failureId.value()); return; } - view->removeFailure(failureId); + hideFailures({.failureIds = {failureId}}); } void retryAllFailures(Ids::WatchId const& watchId) { - const auto listed = failures.failuresOf(watchId); - if (listed.empty()) + std::vector retried; + for (auto const& failure : failures.failuresOf(watchId)) + { + if (auto taken = failures.retry(failure.failureId)) + retried.push_back(std::move(*taken)); + } + if (retried.empty()) + { Log::warn("FileTracking: watch {} has no failures to retry", watchId.value()); - for (auto const& failure : listed) - retryFailure(failure.failureId); + return; + } + hideFailures(removedFailuresOf(retried)); + for (auto const& failure : retried) + retryUpload(failure); } void dismissAllFailures(Ids::WatchId const& watchId) { - const auto listed = failures.failuresOf(watchId); - if (listed.empty()) + std::vector dismissed; + for (auto const& failure : failures.failuresOf(watchId)) + { + if (auto removed = failures.remove(failure.failureId)) + dismissed.push_back(std::move(*removed)); + } + if (dismissed.empty()) Log::warn("FileTracking: watch {} has no failures to dismiss", watchId.value()); - for (auto const& failure : listed) - dismissFailure(failure.failureId); + hideFailures(removedFailuresOf(dismissed)); } /** @@ -2369,7 +2405,7 @@ namespace FileTracking void clearFailure(Ids::WatchId const& watchId, Paths::LocalPath const& localPath) { if (const auto cleared = failures.removePath(watchId, localPath)) - view->removeFailure(cleared->failureId); + hideFailures({.failureIds = {cleared->failureId}}); } /** @@ -2386,8 +2422,7 @@ namespace FileTracking if (remotePath && recordFailure(entry, failed.path, *remotePath, failed.failure)) leftOut.insert(failed.path); } - for (auto const& cleared : failures.removeWithin(entry.watchId, upload.change.path, leftOut)) - view->removeFailure(cleared.failureId); + hideFailures(removedFailuresOf(failures.removeWithin(entry.watchId, upload.change.path, leftOut))); } void removeStopped(Ids::WatchId const& watchId) diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 03e87698..88cc2e41 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -213,11 +214,17 @@ namespace Test events.push_back("conflict " + conflict.conflictId.value()); } - void removeConflict(Ids::ConflictId const& conflictId) override + void removeConflicts(SharedData::RemovedConflicts const& removal) override { - conflicts.erase(conflictId.value()); - removedConflicts.push_back(conflictId.value()); - events.push_back("removeConflict " + conflictId.value()); + std::string event{"removeConflicts"}; + for (auto const& conflictId : removal.conflictIds) + { + conflicts.erase(conflictId.value()); + removedConflicts.push_back(conflictId.value()); + event += " " + conflictId.value(); + } + conflictRemovals.push_back(removal.conflictIds.size()); + events.push_back(std::move(event)); } void showFailure(SharedData::UploadFailure const& failure) override @@ -226,11 +233,17 @@ namespace Test events.push_back("failure " + failure.failureId.value()); } - void removeFailure(Ids::FailureId const& failureId) override + void removeFailures(SharedData::RemovedFailures const& removal) override { - failures.erase(failureId.value()); - removedFailures.push_back(failureId.value()); - events.push_back("removeFailure " + failureId.value()); + std::string event{"removeFailures"}; + for (auto const& failureId : removal.failureIds) + { + failures.erase(failureId.value()); + removedFailures.push_back(failureId.value()); + event += " " + failureId.value(); + } + failureRemovals.push_back(removal.failureIds.size()); + events.push_back(std::move(event)); } void showWatchProgress(SharedData::WatchProgress const& shown) override @@ -253,10 +266,16 @@ namespace Test std::map conflicts{}; /** @brief Every conflict shown, in order. */ std::vector shownConflicts{}; + /** @brief Every conflict id removed, in order, flattened over the messages. */ std::vector removedConflicts{}; + /** @brief How many conflicts each removal message carried. */ + std::vector conflictRemovals{}; /** @brief The failures shown and not removed, by id. */ std::map failures{}; + /** @brief Every failure id removed, in order, flattened over the messages. */ std::vector removedFailures{}; + /** @brief How many failures each removal message carried. */ + std::vector failureRemovals{}; std::vector watchProgress{}; /** @brief When each of watchProgress was shown. */ std::vector watchProgressShownAt{}; @@ -1503,6 +1522,7 @@ namespace Test EXPECT_TRUE(view_->conflicts.empty()); EXPECT_EQ(view_->removedConflicts, (std::vector{mainConflict.value(), utilConflict.value()})); + EXPECT_EQ(view_->conflictRemovals, std::vector{2u}) << "one message for all answered conflicts"; const auto uploads = remote_->sourcesOf("upload"); ASSERT_EQ(uploads.size(), uploadsBefore + 2u); EXPECT_EQ(uploads[uploadsBefore], main_.string()); @@ -1611,9 +1631,8 @@ namespace Test EXPECT_EQ( view_->events, (std::vector{ - "removeConflict " + mainConflict.value(), - "removeConflict " + utilConflict.value(), - "removeFailure " + failureId, + "removeConflicts " + mainConflict.value() + " " + utilConflict.value(), + "removeFailures " + failureId, "progress " + watchId.value(), "entry " + watchId.value() + " StoppedByUser", "orphans 0", @@ -4605,6 +4624,7 @@ namespace Test EXPECT_TRUE(view_->failures.empty()); EXPECT_EQ(view_->removedFailures.size(), 2u); + EXPECT_EQ(view_->failureRemovals, std::vector{2u}) << "one message for all retried failures"; ASSERT_TRUE(runUntil( [this, uploadsBefore]() { @@ -4650,6 +4670,7 @@ namespace Test tracker_->dismissAllFailures(onlyEntry().watchId); EXPECT_TRUE(view_->failures.empty()); EXPECT_EQ(view_->removedFailures, (std::vector{mainFailure.value(), utilFailure.value()})); + EXPECT_EQ(view_->failureRemovals.back(), 1u) << "the rest of the watch left in one message"; runFor(std::chrono::milliseconds{200}); EXPECT_EQ(remote_->of("upload").size(), uploadsBefore); } From dbf89ccd3567834862d88b94de87dbb585de8bba Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 22:05:22 +0200 Subject: [PATCH 103/114] Added a newest-first problem list with id lookup and page slices --- .../file_tracking/problem_list.hpp | 208 +++++++++++++++ frontend/test/frontend/main.cpp | 1 + frontend/test/frontend/test_problem_list.hpp | 249 ++++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 frontend/include/frontend/session_components/file_tracking/problem_list.hpp create mode 100644 frontend/test/frontend/test_problem_list.hpp diff --git a/frontend/include/frontend/session_components/file_tracking/problem_list.hpp b/frontend/include/frontend/session_components/file_tracking/problem_list.hpp new file mode 100644 index 00000000..468e6907 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/problem_list.hpp @@ -0,0 +1,208 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The conflicts or the failures of one watch, newest first: a show of a new id goes to the front, a show of + * a known id replaces its value and moves it to the front. Lookups by id and the size are cheap, a page is a + * slice by offset. Pure, tested natively; TrackingModel keeps one per watch and kind. + */ + template + class ProblemList + { + public: + /** + * @brief One problem with the sequence of its last show. + */ + struct ListedProblem + { + std::uint64_t sequence; + IdT id; + ValueT value; + }; + + using const_iterator = typename std::deque::const_iterator; + + /** + * @brief Shows @p value under @p id, replacing what @p id held and moving it to its place by @p sequence. + * @param sequence Greater is newer. Distinct per show; the newest show passes the greatest and lands in front. + * @return Whether @p id was new. + */ + bool show(IdT const& id, ValueT value, std::uint64_t sequence) + { + const auto [known, inserted] = sequences_.try_emplace(id, sequence); + if (!inserted) + { + problems_.erase(positionOf(known->second)); + known->second = sequence; + } + problems_.insert( + positionOf(sequence), ListedProblem{.sequence = sequence, .id = id, .value = std::move(value)} + ); + return inserted; + } + + /** + * @brief Forgets @p id. + * @return Whether it was known. + */ + bool remove(IdT const& id) + { + const auto known = sequences_.find(id); + if (known == sequences_.end()) + return false; + problems_.erase(positionOf(known->second)); + sequences_.erase(known); + return true; + } + + /** + * @brief Forgets every one of @p ids in one pass over the list; unknown and repeated ids are skipped. + * @return How many were known. + */ + std::size_t remove(std::vector const& ids) + { + std::vector removed{}; + removed.reserve(ids.size()); + for (auto const& id : ids) + { + if (const auto known = sequences_.find(id); known != sequences_.end()) + { + removed.push_back(known->second); + sequences_.erase(known); + } + } + if (removed.size() == 1u) + problems_.erase(positionOf(removed.front())); + else if (!removed.empty()) + { + std::ranges::sort(removed); + std::erase_if( + problems_, + [&removed](ListedProblem const& problem) + { + return std::ranges::binary_search(removed, problem.sequence); + } + ); + } + return removed.size(); + } + + /** + * @brief Forgets every problem. + */ + void clear() + { + problems_.clear(); + sequences_.clear(); + } + + /** + * @return The value of @p id, nullptr when it is not known. + */ + ValueT const* find(IdT const& id) const + { + const auto rank = rankOf(id); + return rank ? &problems_[*rank].value : nullptr; + } + + /** + * @return Whether @p id is known. + */ + bool contains(IdT const& id) const + { + return sequences_.contains(id); + } + + /** + * @return Where @p id is listed, 0 for the newest, nothing when it is not known. + */ + std::optional rankOf(IdT const& id) const + { + const auto known = sequences_.find(id); + if (known == sequences_.end()) + return std::nullopt; + return static_cast(std::distance(problems_.begin(), positionOf(known->second))); + } + + /** + * @return The values from rank @p offset on, at most @p count of them, newest first. + */ + std::vector slice(std::size_t offset, std::size_t count) const + { + if (offset >= problems_.size()) + return {}; + const auto end = offset + std::min(count, problems_.size() - offset); + std::vector values{}; + values.reserve(end - offset); + for (auto rank = offset; rank < end; ++rank) + values.push_back(problems_[rank].value); + return values; + } + + /** + * @return The newest problem, nullptr while the list is empty. + */ + ListedProblem const* newest() const + { + return problems_.empty() ? nullptr : &problems_.front(); + } + + /** + * @return The number of problems. + */ + std::size_t size() const + { + return problems_.size(); + } + + /** + * @return Whether there is no problem. + */ + bool empty() const + { + return problems_.empty(); + } + + /** + * @return The first problem, the newest, for iterating newest first. + */ + const_iterator begin() const + { + return problems_.cbegin(); + } + + /** + * @return Past the oldest problem. + */ + const_iterator end() const + { + return problems_.cend(); + } + + private: + /** + * @return The first problem not newer than @p sequence: the one shown with it, or where it goes. + */ + auto positionOf(std::uint64_t sequence) const + { + return std::ranges::lower_bound(problems_, sequence, std::greater<>{}, &ListedProblem::sequence); + } + + private: + /** @brief Sorted by descending sequence. */ + std::deque problems_{}; + std::map sequences_{}; + }; +} diff --git a/frontend/test/frontend/main.cpp b/frontend/test/frontend/main.cpp index fb37b288..01dae755 100644 --- a/frontend/test/frontend/main.cpp +++ b/frontend/test/frontend/main.cpp @@ -7,6 +7,7 @@ #include "test_numbered_saves.hpp" #include "test_overwrite_decisions.hpp" #include "test_pending_selection.hpp" +#include "test_problem_list.hpp" #include "test_selection_model.hpp" #include "test_side_paths.hpp" #include "test_tracking_model.hpp" diff --git a/frontend/test/frontend/test_problem_list.hpp b/frontend/test/frontend/test_problem_list.hpp new file mode 100644 index 00000000..27d8a261 --- /dev/null +++ b/frontend/test/frontend/test_problem_list.hpp @@ -0,0 +1,249 @@ +#pragma once + +#include + +#include + +#include +#include +#include +#include +#include + +namespace FileTracking::Test +{ + namespace ProblemListTestDetail + { + using Names = std::vector; + using List = ProblemList; + + /** + * @brief Shows each of @p names under its own name with the value "-value", oldest first. + */ + inline void showEach(List& list, Names const& names, std::uint64_t& sequence) + { + for (auto const& name : names) + list.show(name, name + "-value", ++sequence); + } + + inline Names idsOf(List const& list) + { + Names ids{}; + for (auto const& problem : list) + ids.push_back(problem.id); + return ids; + } + } + + TEST(ProblemListTests, AnEmptyListHasNothing) + { + using namespace ProblemListTestDetail; + const List list{}; + + EXPECT_TRUE(list.empty()); + EXPECT_EQ(list.size(), std::size_t{0}); + EXPECT_EQ(list.newest(), nullptr); + EXPECT_EQ(list.find("unknown"), nullptr); + EXPECT_FALSE(list.contains("unknown")); + EXPECT_EQ(list.rankOf("unknown"), std::nullopt); + EXPECT_EQ(list.slice(0u, 100u), Names{}); + } + + TEST(ProblemListTests, NewProblemsGoToTheFront) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third"}, sequence); + + EXPECT_EQ(idsOf(list), (Names{"third", "second", "first"})); + EXPECT_EQ(list.size(), std::size_t{3}); + ASSERT_NE(list.newest(), nullptr); + EXPECT_EQ(list.newest()->id, "third"); + EXPECT_EQ(list.newest()->sequence, 3u); + EXPECT_EQ(list.slice(0u, 100u), (Names{"third-value", "second-value", "first-value"})); + } + + TEST(ProblemListTests, ShowingAKnownIdAgainReplacesItAndMovesItToTheFront) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third"}, sequence); + + EXPECT_FALSE(list.show("first", "first-again", ++sequence)); + + EXPECT_EQ(idsOf(list), (Names{"first", "third", "second"})); + EXPECT_EQ(list.size(), std::size_t{3}); + ASSERT_NE(list.find("first"), nullptr); + EXPECT_EQ(*list.find("first"), "first-again"); + EXPECT_EQ(list.rankOf("first"), std::optional{0}); + } + + TEST(ProblemListTests, ShowTellsWhetherTheIdWasNew) + { + using namespace ProblemListTestDetail; + List list{}; + + EXPECT_TRUE(list.show("problem", "value", 1u)); + EXPECT_FALSE(list.show("problem", "value", 2u)); + EXPECT_EQ(list.size(), std::size_t{1}); + } + + TEST(ProblemListTests, AnOlderSequenceIsSortedInBehindNewerOnes) + { + using namespace ProblemListTestDetail; + List list{}; + list.show("newest", "value", 30u); + list.show("oldest", "value", 10u); + list.show("middle", "value", 20u); + + EXPECT_EQ(idsOf(list), (Names{"newest", "middle", "oldest"})); + } + + TEST(ProblemListTests, RanksCountFromTheNewest) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third", "fourth"}, sequence); + + EXPECT_EQ(list.rankOf("fourth"), std::optional{0}); + EXPECT_EQ(list.rankOf("third"), std::optional{1}); + EXPECT_EQ(list.rankOf("second"), std::optional{2}); + EXPECT_EQ(list.rankOf("first"), std::optional{3}); + EXPECT_TRUE(list.contains("second")); + ASSERT_NE(list.find("second"), nullptr); + EXPECT_EQ(*list.find("second"), "second-value"); + } + + TEST(ProblemListTests, RemovingOneKeepsTheOrderOfTheOthers) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third"}, sequence); + + EXPECT_TRUE(list.remove(std::string{"second"})); + EXPECT_FALSE(list.remove(std::string{"second"})); + EXPECT_FALSE(list.remove(std::string{"unknown"})); + + EXPECT_EQ(idsOf(list), (Names{"third", "first"})); + EXPECT_FALSE(list.contains("second")); + EXPECT_EQ(list.find("second"), nullptr); + EXPECT_EQ(list.rankOf("first"), std::optional{1}); + } + + TEST(ProblemListTests, ARemovedIdComesBackAsNew) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second"}, sequence); + list.remove(std::string{"first"}); + + EXPECT_TRUE(list.show("first", "back", ++sequence)); + EXPECT_EQ(idsOf(list), (Names{"first", "second"})); + } + + TEST(ProblemListTests, RemovingManySkipsUnknownAndRepeatedIds) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third", "fourth", "fifth"}, sequence); + + EXPECT_EQ(list.remove(Names{"fourth", "unknown", "first", "fourth", "second"}), std::size_t{3}); + + EXPECT_EQ(idsOf(list), (Names{"fifth", "third"})); + EXPECT_EQ(list.size(), std::size_t{2}); + EXPECT_EQ(list.rankOf("third"), std::optional{1}); + EXPECT_FALSE(list.contains("first")); + EXPECT_FALSE(list.contains("second")); + EXPECT_FALSE(list.contains("fourth")); + } + + TEST(ProblemListTests, RemovingManyWithOneKnownIdRemovesIt) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third"}, sequence); + + EXPECT_EQ(list.remove(Names{"unknown", "second"}), std::size_t{1}); + EXPECT_EQ(list.remove(Names{"unknown"}), std::size_t{0}); + EXPECT_EQ(list.remove(Names{}), std::size_t{0}); + + EXPECT_EQ(idsOf(list), (Names{"third", "first"})); + } + + TEST(ProblemListTests, RemovingEveryIdEmptiesTheList) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third"}, sequence); + + EXPECT_EQ(list.remove(Names{"third", "first", "second"}), std::size_t{3}); + + EXPECT_TRUE(list.empty()); + EXPECT_EQ(list.newest(), nullptr); + } + + TEST(ProblemListTests, SlicesArePagesFromAnOffset) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + for (auto index = 0; index < 250; ++index) + list.show(std::to_string(index), std::to_string(index), ++sequence); + + const auto first = list.slice(0u, 100u); + ASSERT_EQ(first.size(), std::size_t{100}); + EXPECT_EQ(first.front(), "249"); + EXPECT_EQ(first.back(), "150"); + + const auto second = list.slice(100u, 100u); + ASSERT_EQ(second.size(), std::size_t{100}); + EXPECT_EQ(second.front(), "149"); + EXPECT_EQ(second.back(), "50"); + + const auto last = list.slice(200u, 100u); + ASSERT_EQ(last.size(), std::size_t{50}); + EXPECT_EQ(last.front(), "49"); + EXPECT_EQ(last.back(), "0"); + + EXPECT_EQ(list.slice(250u, 100u), Names{}); + EXPECT_EQ(list.slice(1000u, 100u), Names{}); + EXPECT_EQ(list.slice(10u, 0u), Names{}); + } + + TEST(ProblemListTests, ANewProblemShiftsEveryPageByOne) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second", "third", "fourth"}, sequence); + + EXPECT_EQ(list.slice(2u, 2u), (Names{"second-value", "first-value"})); + + list.show("fifth", "fifth-value", ++sequence); + + EXPECT_EQ(list.slice(2u, 2u), (Names{"third-value", "second-value"})); + EXPECT_EQ(list.rankOf("first"), std::optional{4}); + } + + TEST(ProblemListTests, ClearForgetsEverything) + { + using namespace ProblemListTestDetail; + List list{}; + std::uint64_t sequence = 0u; + showEach(list, {"first", "second"}, sequence); + list.clear(); + + EXPECT_TRUE(list.empty()); + EXPECT_FALSE(list.contains("first")); + EXPECT_TRUE(list.show("first", "again", ++sequence)); + EXPECT_EQ(list.size(), std::size_t{1}); + } +} From 17c879ae1f85684b0990f30d1a6711bdeec9cf2f Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 22:10:45 +0200 Subject: [PATCH 104/114] Kept problems per watch and synced only the shown page of each list - TrackingModel: a ProblemList per watch and kind, id to watch lookups, running totals; counts are O(1) - TrackingStore: rows of the current page only (100), batch removals, conflict and failure page setters - TrackerClient: onConflictsRemoved/onFailuresRemoved, one setTimeout sync per burst of events --- .../file_tracking/panel_state.hpp | 6 + .../file_tracking/tracker_client.hpp | 6 +- .../file_tracking/tracking_items.hpp | 14 +- .../file_tracking/tracking_model.hpp | 52 +++- .../file_tracking/tracking_store.hpp | 26 +- .../file_tracking/tracker_client.cpp | 48 +++- .../file_tracking/tracking_model.cpp | 215 ++++++++------ .../file_tracking/tracking_store.cpp | 135 ++++++--- .../test/frontend/test_tracking_model.hpp | 267 ++++++++++++++++-- 9 files changed, 589 insertions(+), 180 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/panel_state.hpp b/frontend/include/frontend/session_components/file_tracking/panel_state.hpp index 6c05629a..676b36f9 100644 --- a/frontend/include/frontend/session_components/file_tracking/panel_state.hpp +++ b/frontend/include/frontend/session_components/file_tracking/panel_state.hpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace FileTracking @@ -44,6 +45,11 @@ namespace FileTracking DetailTab::Options, }; + /** + * @brief How many conflicts or failures one page of the Conflicts and Failed tabs shows. + */ + inline constexpr std::size_t problemPageSize = 100u; + /** * @brief How Up, Down, Home and End move the selection through the expanded outliner groups. */ diff --git a/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp b/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp index ffe6323e..1ad16b0f 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracker_client.hpp @@ -19,8 +19,8 @@ namespace FileTracking /** * @brief Talks to the tracker of a session, which lives in the backend as "FileTracker::". Registers - * every event and feeds it to the TrackingStore, syncing once per event; has one method per RPC. Owns no - * panel state. A refusal of a fire-and-forget RPC is only logged. + * every event and feeds it to the TrackingStore, syncing once per burst of events from a task of its own; + * has one method per RPC. Owns no panel state. A refusal of a fire-and-forget RPC is only logged. */ class TrackerClient { @@ -34,7 +34,7 @@ namespace FileTracking /** * @brief Follows the tracker of @p sessionId from now on: deactivates first, registers onEntry, onEntryRemoved, - * onEntriesEnded, onConflict, onConflictRemoved, onFailure, onFailureRemoved, onWatchProgress and + * onEntriesEnded, onConflict, onConflictsRemoved, onFailure, onFailuresRemoved, onWatchProgress and * onOrphanedCopies, then asks for the orphan list. */ void activate(Ids::SessionId sessionId); diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp index 7fc354e5..e0f68794 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp @@ -105,12 +105,22 @@ namespace FileTracking /** @brief One row per file in flight, laid out by InFlightRows so rows do not shift. */ Nui::Observed>> inFlight{}; - /** @brief Newest first, as TrackingModel::conflictsOf orders them. */ + /** @brief The conflicts of the page shown, newest first, as TrackingModel::conflictsOf orders them. */ Nui::Observed>> conflicts{}; - /** @brief Newest first, as TrackingModel::failuresOf orders them. */ + /** @brief The failures of the page shown, newest first, as TrackingModel::failuresOf orders them. */ Nui::Observed>> failures{}; + /** @brief Every conflict of the watch, not only those of the page shown. */ std::shared_ptr> conflictCount{std::make_shared>(0u)}; + /** @brief Every failure of the watch, not only those of the page shown. */ std::shared_ptr> failureCount{std::make_shared>(0u)}; + /** @brief The page of the Conflicts tab shown, 0-based. */ + std::shared_ptr> conflictPage{std::make_shared>(0)}; + /** @brief The pages of the Conflicts tab, at least 1. */ + std::shared_ptr> conflictPageCount{std::make_shared>(1)}; + /** @brief The page of the Failed tab shown, 0-based. */ + std::shared_ptr> failurePage{std::make_shared>(0)}; + /** @brief The pages of the Failed tab, at least 1. */ + std::shared_ptr> failurePageCount{std::make_shared>(1)}; }; /** diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp index 205ef665..32a69855 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -9,6 +10,7 @@ #include #include +#include #include #include #include @@ -24,6 +26,9 @@ namespace FileTracking */ bool matchesFilter(std::string_view name, std::string_view filter); + using ConflictList = ProblemList; + using FailureList = ProblemList; + /** * @brief Everything the tracker of one session told the panel, and the rules that sort it into the outliner. Pure, * tested natively; TrackingStore projects it onto observed values. @@ -42,24 +47,27 @@ namespace FileTracking void removeEntry(Ids::WatchId const& watchId); /** - * @brief Adds @p conflict or replaces the one with its id. Kept even while its watch is not known yet. + * @brief Adds @p conflict or replaces the one with its id, as the newest of its watch. Kept even while its + * watch is not known yet. */ void showConflict(SharedData::WatchConflict const& conflict); /** - * @brief Forgets the conflict @p conflictId, it was answered or dropped. + * @brief Forgets the conflicts @p conflictIds, they were answered or dropped. Unknown ids are skipped. */ - void removeConflict(Ids::ConflictId const& conflictId); + void removeConflicts(std::vector const& conflictIds); /** - * @brief Adds @p failure or replaces the one with its id (a repeated failure keeps its id and counts attempts). + * @brief Adds @p failure or replaces the one with its id (a repeated failure keeps its id and counts attempts), + * as the newest of its watch. */ void showFailure(SharedData::UploadFailure const& failure); /** - * @brief Forgets the failure @p failureId, it went through, was retried or dismissed. + * @brief Forgets the failures @p failureIds, they went through, were retried or dismissed. Unknown ids are + * skipped. */ - void removeFailure(Ids::FailureId const& failureId); + void removeFailures(std::vector const& failureIds); /** * @brief Replaces the progress of the watch @p progress names. @@ -102,15 +110,16 @@ namespace FileTracking std::optional watchOfFailure(Ids::FailureId const& failureId) const; /** - * @return The conflicts of @p watchId, newest raisedAt first, ties by the greater conflict id first like - * @ref newestConflict. + * @return At most @p count conflicts of @p watchId from rank @p offset on, the last shown first. */ - std::vector conflictsOf(Ids::WatchId const& watchId) const; + std::vector + conflictsOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const; /** - * @return The failures of @p watchId, newest failedAt first, ties by the greater failure id first. + * @return At most @p count failures of @p watchId from rank @p offset on, the last shown first. */ - std::vector failuresOf(Ids::WatchId const& watchId) const; + std::vector + failuresOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const; /** * @return The number of conflicts of @p watchId. @@ -171,15 +180,30 @@ namespace FileTracking double ringFractionOf(Ids::WatchId const& watchId) const; /** - * @return The conflict with the latest raisedAt among the known watches, ties by the greater conflict id. + * @return The conflict shown last among the known watches, the first of its watch's conflicts. */ std::optional newestConflict() const; private: + /** + * @brief The conflicts or the failures of every watch, the watch of each id, and how many the known watches + * have. + */ + template + struct ProblemsByWatch + { + std::map lists{}; + std::map watches{}; + /** @brief The problems of the watches entries_ holds. */ + std::uint64_t knownTotal{0u}; + }; + std::map entries_{}; std::map progress_{}; - std::map conflicts_{}; - std::map failures_{}; + ProblemsByWatch conflicts_{}; + ProblemsByWatch failures_{}; std::vector orphans_{}; + /** @brief Orders every show of a conflict or failure, so the newest of different watches compare. */ + std::uint64_t sequence_{0u}; }; } diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp index cbb9f2a0..682793a8 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp @@ -47,24 +47,36 @@ namespace FileTracking void removeEntry(Ids::WatchId const& watchId); /** - * @brief Adds or replaces @p conflict in its watch's list and updates the watch's counts, status and group. + * @brief Adds or replaces @p conflict as the newest of its watch and updates the watch's counts, status, group + * and the rows of its page shown. */ void showConflict(SharedData::WatchConflict const& conflict); /** - * @brief Drops the conflict @p conflictId from its watch's list and updates the watch. + * @brief Drops the conflicts @p conflictIds and updates each watch they belonged to once. */ - void removeConflict(Ids::ConflictId const& conflictId); + void removeConflicts(std::vector const& conflictIds); /** - * @brief Adds or replaces @p failure in its watch's list and updates the watch's counts, status and group. + * @brief Adds or replaces @p failure as the newest of its watch and updates the watch's counts, status, group + * and the rows of its page shown. */ void showFailure(SharedData::UploadFailure const& failure); /** - * @brief Drops the failure @p failureId from its watch's list and updates the watch. + * @brief Drops the failures @p failureIds and updates each watch they belonged to once. */ - void removeFailure(Ids::FailureId const& failureId); + void removeFailures(std::vector const& failureIds); + + /** + * @brief Shows page @p page (0-based, clamped to the pages there are) of the Conflicts tab of @p watchId. + */ + void setConflictPage(Ids::WatchId const& watchId, int page); + + /** + * @brief Shows page @p page (0-based, clamped to the pages there are) of the Failed tab of @p watchId. + */ + void setFailurePage(Ids::WatchId const& watchId, int page); /** * @brief Updates the progress, the bars, the in-flight rows, the ring and the status of the watch @p progress @@ -171,7 +183,7 @@ namespace FileTracking void setGroupExpanded(OutlinerGroup group, bool expanded); /** - * @brief Selects the watch of the newest conflict and its Conflicts tab. + * @brief Selects the watch of the newest conflict and its Conflicts tab on the first page, which shows it. * @return Whether there was a conflict to show. */ bool focusNewestConflict(); diff --git a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp index 2ddb0f6c..d34c2cfc 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -10,6 +11,8 @@ #include #include +#include +#include #include #include @@ -32,6 +35,8 @@ namespace FileTracking std::function onEnded; Ids::SessionId sessionId{}; std::vector listeners{}; + /** @brief Set from the first event after a sync until the sync it scheduled ran. */ + bool syncScheduled{false}; Utility::LifetimeToken lifetime{}; /** @@ -42,6 +47,27 @@ namespace FileTracking Nui::globalEventContext.executeActiveEventsImmediately(); } + /** + * @brief Shows what the events since the last sync changed from a task of its own, so a burst of events syncs + * once. Events arriving before that task ran schedule nothing more. + */ + void scheduleSync() + { + if (syncScheduled) + return; + syncScheduled = true; + Nui::val::global("setTimeout")( + Nui::bind(lifetime.guarded( + [this]() + { + syncScheduled = false; + syncView(); + } + )), + Nui::val{0} + ); + } + static bool succeeded(Nui::val const& response) { return response.hasOwnProperty("success") && response["success"].as(); @@ -80,7 +106,7 @@ namespace FileTracking } /** - * @brief Registers @p event of the tracker, @p handler gets its payload and the view syncs once after it. + * @brief Registers @p event of the tracker, @p handler gets its payload and a sync is scheduled after it. */ template void listenTo(std::string_view event, HandlerT handler) @@ -89,10 +115,10 @@ namespace FileTracking Nui::RpcClient::autoRegisterFunction( rpcName(event), lifetime.guarded( - [handler = std::move(handler)](PayloadT const& payload) + [this, handler = std::move(handler)](PayloadT const& payload) { handler(payload); - syncView(); + scheduleSync(); } ) ) @@ -130,11 +156,11 @@ namespace FileTracking store->showConflict(conflict); } ); - listenTo( - "onConflictRemoved", - [this](std::string const& conflictId) + listenTo( + "onConflictsRemoved", + [this](SharedData::RemovedConflicts const& removed) { - store->removeConflict(Ids::makeConflictId(conflictId)); + store->removeConflicts(removed.conflictIds); } ); listenTo( @@ -144,11 +170,11 @@ namespace FileTracking store->showFailure(failure); } ); - listenTo( - "onFailureRemoved", - [this](std::string const& failureId) + listenTo( + "onFailuresRemoved", + [this](SharedData::RemovedFailures const& removed) { - store->removeFailure(Ids::makeFailureId(failureId)); + store->removeFailures(removed.failureIds); } ); listenTo( diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp index 05128de6..7046b9b9 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp @@ -1,9 +1,14 @@ #include #include +#include +#include #include +#include +#include #include #include +#include namespace FileTracking { @@ -56,52 +61,109 @@ namespace FileTracking return result; } - bool isNewer(SharedData::WatchConflict const& conflict, SharedData::WatchConflict const& other) + /** + * @return Whether a watch id is one of @p entries. + */ + auto knownIn(std::map const& entries) + { + return [&entries](Ids::WatchId const& watchId) + { + return entries.contains(watchId); + }; + } + + template + std::uint64_t countOf(ProblemsT const& problems, Ids::WatchId const& watchId) + { + const auto list = problems.lists.find(watchId); + return list == problems.lists.end() ? 0u : static_cast(list->second.size()); + } + + template + std::optional watchOf(ProblemsT const& problems, IdT const& problemId) + { + const auto found = problems.watches.find(problemId); + if (found == problems.watches.end()) + return std::nullopt; + return found->second; + } + + template + auto sliceOf(ProblemsT const& problems, Ids::WatchId const& watchId, std::size_t offset, std::size_t count) + -> decltype(problems.lists.begin()->second.slice(offset, count)) { - if (conflict.raisedAt != other.raisedAt) - return conflict.raisedAt > other.raisedAt; - return conflict.conflictId > other.conflictId; + const auto list = problems.lists.find(watchId); + if (list == problems.lists.end()) + return {}; + return list->second.slice(offset, count); } - bool isNewer(SharedData::UploadFailure const& failure, SharedData::UploadFailure const& other) + /** + * @brief Removes @p problemIds (one id or a vector of them) from the list of @p watchId, dropping the list + * once it is empty. The caller keeps the id to watch lookup. + */ + template + void removeFromWatch(ProblemsT& problems, Ids::WatchId const& watchId, IdsT const& problemIds, bool known) { - if (failure.failedAt != other.failedAt) - return failure.failedAt > other.failedAt; - return failure.failureId > other.failureId; + const auto list = problems.lists.find(watchId); + if (list == problems.lists.end()) + return; + const auto removed = static_cast(list->second.remove(problemIds)); + if (known) + problems.knownTotal -= removed; + if (list->second.empty()) + problems.lists.erase(list); } - template - std::vector newestFirstOf(MapT const& items, Ids::WatchId const& watchId) + template + void showProblem( + ProblemsT& problems, + IdT const& problemId, + ValueT const& value, + std::uint64_t sequence, + IsKnownT const& isKnown + ) { - std::vector result; - for (auto const& item : items | std::views::values) + const auto [watch, inserted] = problems.watches.try_emplace(problemId, value.watchId); + if (!inserted && watch->second != value.watchId) { - if (item.watchId == watchId) - result.push_back(item); + removeFromWatch(problems, watch->second, problemId, isKnown(watch->second)); + watch->second = value.watchId; } - std::ranges::sort( - result, - [](auto const& left, auto const& right) - { - return isNewer(left, right); - } - ); - return result; + if (problems.lists[value.watchId].show(problemId, value, sequence) && isKnown(value.watchId)) + ++problems.knownTotal; } - template - std::uint64_t countOf(MapT const& items, PredicateT predicate) + /** + * @brief Removes @p problemIds, one pass over the list of each watch they belong to. + */ + template + void removeProblems(ProblemsT& problems, std::vector const& problemIds, IsKnownT const& isKnown) { - return static_cast(std::ranges::count_if(items | std::views::values, predicate)); + std::map> byWatch{}; + for (auto const& problemId : problemIds) + { + const auto watch = problems.watches.find(problemId); + if (watch == problems.watches.end()) + continue; + byWatch[watch->second].push_back(problemId); + problems.watches.erase(watch); + } + for (auto const& [watchId, ofWatch] : byWatch) + removeFromWatch(problems, watchId, ofWatch, isKnown(watchId)); } - template - std::optional watchOf(MapT const& items, IdT const& itemId) + template + void dropWatch(ProblemsT& problems, Ids::WatchId const& watchId, bool known) { - const auto found = items.find(itemId); - if (found == items.end()) - return std::nullopt; - return found->second.watchId; + const auto list = problems.lists.find(watchId); + if (list == problems.lists.end()) + return; + for (auto const& problem : list->second) + problems.watches.erase(problem.id); + if (known) + problems.knownTotal -= static_cast(list->second.size()); + problems.lists.erase(list); } bool isHeldByAnEntry( @@ -130,40 +192,39 @@ namespace FileTracking void TrackingModel::showEntry(SharedData::TrackedEntry const& entry) { - entries_.insert_or_assign(entry.watchId, entry); + const auto [shown, inserted] = entries_.insert_or_assign(entry.watchId, entry); + if (!inserted) + return; + conflicts_.knownTotal += countOf(conflicts_, entry.watchId); + failures_.knownTotal += countOf(failures_, entry.watchId); } void TrackingModel::removeEntry(Ids::WatchId const& watchId) { - const auto removed = watchId; - const auto ofRemoved = [&removed](auto const& item) - { - return item.second.watchId == removed; - }; - entries_.erase(removed); - progress_.erase(removed); - std::erase_if(conflicts_, ofRemoved); - std::erase_if(failures_, ofRemoved); + const auto known = entries_.erase(watchId) > 0u; + progress_.erase(watchId); + dropWatch(conflicts_, watchId, known); + dropWatch(failures_, watchId, known); } void TrackingModel::showConflict(SharedData::WatchConflict const& conflict) { - conflicts_.insert_or_assign(conflict.conflictId, conflict); + showProblem(conflicts_, conflict.conflictId, conflict, ++sequence_, knownIn(entries_)); } - void TrackingModel::removeConflict(Ids::ConflictId const& conflictId) + void TrackingModel::removeConflicts(std::vector const& conflictIds) { - conflicts_.erase(conflictId); + removeProblems(conflicts_, conflictIds, knownIn(entries_)); } void TrackingModel::showFailure(SharedData::UploadFailure const& failure) { - failures_.insert_or_assign(failure.failureId, failure); + showProblem(failures_, failure.failureId, failure, ++sequence_, knownIn(entries_)); } - void TrackingModel::removeFailure(Ids::FailureId const& failureId) + void TrackingModel::removeFailures(std::vector const& failureIds) { - failures_.erase(failureId); + removeProblems(failures_, failureIds, knownIn(entries_)); } void TrackingModel::showWatchProgress(SharedData::WatchProgress const& progress) @@ -180,8 +241,8 @@ namespace FileTracking { entries_.clear(); progress_.clear(); - conflicts_.clear(); - failures_.clear(); + conflicts_ = {}; + failures_ = {}; orphans_.clear(); } @@ -221,58 +282,36 @@ namespace FileTracking return watchOf(failures_, failureId); } - std::vector TrackingModel::conflictsOf(Ids::WatchId const& watchId) const + std::vector + TrackingModel::conflictsOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const { - return newestFirstOf(conflicts_, watchId); + return sliceOf(conflicts_, watchId, offset, count); } - std::vector TrackingModel::failuresOf(Ids::WatchId const& watchId) const + std::vector + TrackingModel::failuresOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const { - return newestFirstOf(failures_, watchId); + return sliceOf(failures_, watchId, offset, count); } std::uint64_t TrackingModel::conflictCountOf(Ids::WatchId const& watchId) const { - return countOf( - conflicts_, - [&watchId](SharedData::WatchConflict const& conflict) - { - return conflict.watchId == watchId; - } - ); + return countOf(conflicts_, watchId); } std::uint64_t TrackingModel::failureCountOf(Ids::WatchId const& watchId) const { - return countOf( - failures_, - [&watchId](SharedData::UploadFailure const& failure) - { - return failure.watchId == watchId; - } - ); + return countOf(failures_, watchId); } std::uint64_t TrackingModel::conflictCount() const { - return countOf( - conflicts_, - [this](SharedData::WatchConflict const& conflict) - { - return entries_.contains(conflict.watchId); - } - ); + return conflicts_.knownTotal; } std::uint64_t TrackingModel::failureCount() const { - return countOf( - failures_, - [this](SharedData::UploadFailure const& failure) - { - return entries_.contains(failure.watchId); - } - ); + return failures_.knownTotal; } WatchStatus TrackingModel::statusOf(Ids::WatchId const& watchId) const @@ -379,12 +418,16 @@ namespace FileTracking std::optional TrackingModel::newestConflict() const { - std::optional newest; - for (auto const& conflict : conflicts_ | std::views::values) + ConflictList::ListedProblem const* newest = nullptr; + for (auto const& [watchId, list] : conflicts_.lists) { - if (entries_.contains(conflict.watchId) && (!newest.has_value() || isNewer(conflict, *newest))) - newest = conflict; + auto const* candidate = list.newest(); + if (candidate != nullptr && entries_.contains(watchId) && + (newest == nullptr || candidate->sequence > newest->sequence)) + newest = candidate; } - return newest; + if (newest == nullptr) + return std::nullopt; + return newest->value; } } diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp index 6a3c89da..24d238dd 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -183,12 +184,14 @@ namespace FileTracking } /** - * @brief Updates the rows of @p observed from @p values in their order: an item whose value did not change is - * kept, a changed or new one is made by @p makeItem, all of them when @p rebuild is set. + * @brief Updates the rows of @p observed from @p values in their order: an item of @p visible whose value did + * not change is kept, a changed or new one is made by @p makeItem, all of them when @p rebuild is set. + * @p visible then holds the rows shown, by key. */ - template + template void syncRows( Nui::Observed>>& observed, + std::map>& visible, std::vector const& values, ValueT ItemT::* valueOfItem, KeyOfT keyOf, @@ -196,26 +199,43 @@ namespace FileTracking bool rebuild ) { - using KeyT = std::remove_cvref_t>; - std::map> known{}; - if (!rebuild) - { - for (auto const& item : observed.value()) - known.emplace(keyOf((*item).*valueOfItem), item); - } + std::map> shown{}; std::vector> target{}; target.reserve(values.size()); for (auto const& value : values) { - const auto found = known.find(keyOf(value)); - if (found != known.end() && sameValue((*found->second).*valueOfItem, value)) - target.push_back(found->second); - else - target.push_back(std::make_shared(makeItem(value))); + auto key = keyOf(value); + const auto found = rebuild ? visible.end() : visible.find(key); + auto item = found != visible.end() && sameValue((*found->second).*valueOfItem, value) + ? found->second + : std::make_shared(makeItem(value)); + target.push_back(item); + shown.emplace(std::move(key), std::move(item)); } + visible = std::move(shown); syncList(observed, target); } + /** + * @return The pages of @p count problems, at least 1. + */ + int pageCountOf(std::uint64_t count) + { + return static_cast(std::max(1u, (count + problemPageSize - 1u) / problemPageSize)); + } + + /** + * @brief Updates @p pageCount to the pages of @p count problems and keeps @p page within them. + * @return The rank of the first problem of @p page. + */ + std::size_t showPages(Nui::Observed& page, Nui::Observed& pageCount, std::uint64_t count) + { + const auto pages = pageCountOf(count); + assignIfChanged(pageCount, pages); + assignIfChanged(page, std::clamp(page.value(), 0, pages - 1)); + return static_cast(page.value()) * problemPageSize; + } + std::size_t indexOf(OutlinerGroup group) { return static_cast(group); @@ -245,6 +265,10 @@ namespace FileTracking std::shared_ptr item{}; std::optional group{}; InFlightRows rows{}; + /** @brief The rows of item->conflicts by id. */ + std::map> conflictRows{}; + /** @brief The rows of item->failures by id. */ + std::map> failureRows{}; }; explicit Implementation(FileTrackingTexts texts) @@ -450,11 +474,19 @@ namespace FileTracking showInFlight(watch, *progress, entry.localPath, watchedMoved); } - void showConflicts(WatchItem& item, SharedData::TrackedEntry const& entry, bool watchedMoved) + /** + * @brief Updates the conflict count, the pages and the rows of the page shown of @p watch. + */ + void showConflicts(WatchRecord& watch, SharedData::TrackedEntry const& entry, bool watchedMoved) { + auto& item = *watch.item; + const auto count = model.conflictCountOf(entry.watchId); + assignIfChanged(*item.conflictCount, count); + const auto offset = showPages(*item.conflictPage, *item.conflictPageCount, count); syncRows( item.conflicts, - model.conflictsOf(entry.watchId), + watch.conflictRows, + model.conflictsOf(entry.watchId, offset, problemPageSize), &ConflictItem::conflict, [](SharedData::WatchConflict const& conflict) { @@ -470,14 +502,21 @@ namespace FileTracking }, watchedMoved ); - assignIfChanged(*item.conflictCount, model.conflictCountOf(entry.watchId)); } - void showFailures(WatchItem& item, SharedData::TrackedEntry const& entry, bool watchedMoved) + /** + * @brief Updates the failure count, the pages and the rows of the page shown of @p watch. + */ + void showFailures(WatchRecord& watch, SharedData::TrackedEntry const& entry, bool watchedMoved) { + auto& item = *watch.item; + const auto count = model.failureCountOf(entry.watchId); + assignIfChanged(*item.failureCount, count); + const auto offset = showPages(*item.failurePage, *item.failurePageCount, count); syncRows( item.failures, - model.failuresOf(entry.watchId), + watch.failureRows, + model.failuresOf(entry.watchId, offset, problemPageSize), &FailureItem::failure, [](SharedData::UploadFailure const& failure) { @@ -494,7 +533,6 @@ namespace FileTracking }, watchedMoved ); - assignIfChanged(*item.failureCount, model.failureCountOf(entry.watchId)); } void showSummary(WatchItem& item) @@ -525,9 +563,9 @@ namespace FileTracking if (parts.progress || watchedMoved) showProgress(record, *entry, watchedMoved); if (parts.conflicts || watchedMoved) - showConflicts(item, *entry, watchedMoved); + showConflicts(record, *entry, watchedMoved); if (parts.failures || watchedMoved) - showFailures(item, *entry, watchedMoved); + showFailures(record, *entry, watchedMoved); showSummary(item); const auto group = model.groupOf(watchId); @@ -598,12 +636,17 @@ namespace FileTracking impl_->notifyAttention(); } - void TrackingStore::removeConflict(Ids::ConflictId const& conflictId) + void TrackingStore::removeConflicts(std::vector const& conflictIds) { - const auto watchId = impl_->model.watchOfConflict(conflictId); - impl_->model.removeConflict(conflictId); - if (watchId) - impl_->refreshWatch(*watchId, {.conflicts = true}); + std::set watchIds{}; + for (auto const& conflictId : conflictIds) + { + if (auto watchId = impl_->model.watchOfConflict(conflictId)) + watchIds.insert(std::move(*watchId)); + } + impl_->model.removeConflicts(conflictIds); + for (auto const& watchId : watchIds) + impl_->refreshWatch(watchId, {.conflicts = true}); impl_->notifyAttention(); } @@ -617,15 +660,40 @@ namespace FileTracking impl_->notifyAttention(); } - void TrackingStore::removeFailure(Ids::FailureId const& failureId) + void TrackingStore::removeFailures(std::vector const& failureIds) { - const auto watchId = impl_->model.watchOfFailure(failureId); - impl_->model.removeFailure(failureId); - if (watchId) - impl_->refreshWatch(*watchId, {.failures = true}); + std::set watchIds{}; + for (auto const& failureId : failureIds) + { + if (auto watchId = impl_->model.watchOfFailure(failureId)) + watchIds.insert(std::move(*watchId)); + } + impl_->model.removeFailures(failureIds); + for (auto const& watchId : watchIds) + impl_->refreshWatch(watchId, {.failures = true}); impl_->notifyAttention(); } + void TrackingStore::setConflictPage(Ids::WatchId const& watchId, int page) + { + const auto watch = impl_->watches.find(watchId); + if (watch == impl_->watches.end()) + return; + const auto pages = pageCountOf(impl_->model.conflictCountOf(watchId)); + assignIfChanged(*watch->second.item->conflictPage, std::clamp(page, 0, pages - 1)); + impl_->refreshWatch(watchId, {.conflicts = true}); + } + + void TrackingStore::setFailurePage(Ids::WatchId const& watchId, int page) + { + const auto watch = impl_->watches.find(watchId); + if (watch == impl_->watches.end()) + return; + const auto pages = pageCountOf(impl_->model.failureCountOf(watchId)); + assignIfChanged(*watch->second.item->failurePage, std::clamp(page, 0, pages - 1)); + impl_->refreshWatch(watchId, {.failures = true}); + } + void TrackingStore::showWatchProgress(SharedData::WatchProgress const& progress) { impl_->model.showWatchProgress(progress); @@ -786,6 +854,7 @@ namespace FileTracking return false; select(newest->watchId); setDetailTab(DetailTab::Conflicts); + setConflictPage(newest->watchId, 0); return true; } diff --git a/frontend/test/frontend/test_tracking_model.hpp b/frontend/test/frontend/test_tracking_model.hpp index d8475e0d..2607cfc4 100644 --- a/frontend/test/frontend/test_tracking_model.hpp +++ b/frontend/test/frontend/test_tracking_model.hpp @@ -153,6 +153,53 @@ namespace FileTracking::Test { return namesOf(model.members(group, filter)); } + + inline std::vector + allConflictsOf(TrackingModel const& model, std::string const& watchId) + { + return model.conflictsOf(watchIdOf(watchId), 0u, model.conflictCountOf(watchIdOf(watchId))); + } + + inline std::vector + allFailuresOf(TrackingModel const& model, std::string const& watchId) + { + return model.failuresOf(watchIdOf(watchId), 0u, model.failureCountOf(watchIdOf(watchId))); + } + + inline std::vector conflictIdsOf(Names const& names) + { + std::vector conflictIds{}; + for (auto const& name : names) + conflictIds.push_back(Ids::makeConflictId(name)); + return conflictIds; + } + + inline std::vector failureIdsOf(Names const& names) + { + std::vector failureIds{}; + for (auto const& name : names) + failureIds.push_back(Ids::makeFailureId(name)); + return failureIds; + } + + /** + * @brief Shows @p count conflicts "-0" to "-" of @p watchId, in that order. + */ + inline void + showConflicts(TrackingModel& model, std::string const& prefix, std::string const& watchId, int count) + { + for (auto index = 0; index < count; ++index) + model.showConflict(conflictOf(fmt::format("{}-{}", prefix, index), watchId, 100u)); + } + + /** + * @brief Shows @p count failures "-0" to "-" of @p watchId, in that order. + */ + inline void showFailures(TrackingModel& model, std::string const& prefix, std::string const& watchId, int count) + { + for (auto index = 0; index < count; ++index) + model.showFailure(failureOf(fmt::format("{}-{}", prefix, index), watchId, 100u)); + } } TEST(TrackingModelTests, AnUnknownWatchIsWatchingWithoutText) @@ -310,11 +357,11 @@ namespace FileTracking::Test EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 conflict"); - model.removeConflict(Ids::makeConflictId("conflict")); + model.removeConflicts({Ids::makeConflictId("conflict")}); EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 failed"); - model.removeFailure(Ids::makeFailureId("failure")); + model.removeFailures({Ids::makeFailureId("failure")}); EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "overlap"); } @@ -345,12 +392,12 @@ namespace FileTracking::Test model.showEntry(watchEntry("watch", "notes.txt")); model.showConflict(conflictOf("conflict", "watch", 100u)); model.showFailure(failureOf("failure", "watch", 100u)); - model.removeConflict(Ids::makeConflictId("conflict")); + model.removeConflicts({Ids::makeConflictId("conflict")}); EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Failed); - model.removeFailure(Ids::makeFailureId("failure")); + model.removeFailures({Ids::makeFailureId("failure")}); EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::Watching); EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Watching); @@ -584,8 +631,8 @@ namespace FileTracking::Test EXPECT_EQ(model.progressOf(watchIdOf("removed")), nullptr); EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("removed-conflict")), std::nullopt); EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("removed-failure")), std::nullopt); - EXPECT_TRUE(model.conflictsOf(watchIdOf("removed")).empty()); - EXPECT_TRUE(model.failuresOf(watchIdOf("removed")).empty()); + EXPECT_TRUE(allConflictsOf(model, "removed").empty()); + EXPECT_TRUE(allFailuresOf(model, "removed").empty()); EXPECT_EQ(model.conflictCount(), 1u); EXPECT_EQ(model.failureCount(), 1u); EXPECT_NE(model.progressOf(watchIdOf("kept")), nullptr); @@ -611,7 +658,7 @@ namespace FileTracking::Test replaced.kind = SharedData::ConflictKind::DeletedOnServer; model.showConflict(replaced); - const auto conflicts = model.conflictsOf(watchIdOf("first")); + const auto conflicts = allConflictsOf(model, "first"); ASSERT_EQ(conflicts.size(), 1u); EXPECT_EQ(conflicts.front().raisedAt, 200u); EXPECT_EQ(conflicts.front().kind, SharedData::ConflictKind::DeletedOnServer); @@ -635,7 +682,7 @@ namespace FileTracking::Test repeated.reason = SharedData::UploadFailureReason::NoSpaceOnServer; model.showFailure(repeated); - const auto failures = model.failuresOf(watchIdOf("watch")); + const auto failures = allFailuresOf(model, "watch"); ASSERT_EQ(failures.size(), 1u); EXPECT_EQ(failures.front().attempts, 3u); EXPECT_EQ(failures.front().failedAt, 300u); @@ -650,8 +697,8 @@ namespace FileTracking::Test model.showEntry(watchEntry("watch", "notes.txt")); model.showConflict(conflictOf("conflict", "watch", 100u)); model.showFailure(failureOf("failure", "watch", 100u)); - model.removeConflict(Ids::makeConflictId("unknown")); - model.removeFailure(Ids::makeFailureId("unknown")); + model.removeConflicts({Ids::makeConflictId("unknown")}); + model.removeFailures({Ids::makeFailureId("unknown")}); model.removeEntry(watchIdOf("unknown")); EXPECT_EQ(model.conflictCount(), 1u); @@ -661,7 +708,7 @@ namespace FileTracking::Test EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("unknown")), std::nullopt); } - TEST(TrackingModelTests, TheConflictsOfAWatchStartWithTheNewest) + TEST(TrackingModelTests, TheConflictsOfAWatchStartWithTheLastShown) { using namespace TrackingModelTestDetail; TrackingModel model; @@ -672,24 +719,27 @@ namespace FileTracking::Test model.showConflict(conflictOf("third", "watch", 200u)); model.showConflict(conflictOf("elsewhere", "other", 400u)); - EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("watch"))), (Names{"second", "third", "first"})); + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "watch")), (Names{"third", "second", "first"})); EXPECT_EQ(model.conflictCountOf(watchIdOf("watch")), 3u); EXPECT_EQ(model.conflictCountOf(watchIdOf("other")), 1u); } - TEST(TrackingModelTests, ConflictsRaisedAtTheSameTimeStartWithTheGreaterId) + TEST(TrackingModelTests, AConflictShownAgainMovesToTheFront) { using namespace TrackingModelTestDetail; TrackingModel model; model.showEntry(watchEntry("watch", "project")); model.showConflict(conflictOf("alpha", "watch", 100u)); - model.showConflict(conflictOf("charlie", "watch", 50u)); model.showConflict(conflictOf("bravo", "watch", 100u)); + model.showConflict(conflictOf("charlie", "watch", 100u)); + model.showConflict(conflictOf("alpha", "watch", 200u)); - EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("watch"))), (Names{"bravo", "alpha", "charlie"})); + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "watch")), (Names{"alpha", "charlie", "bravo"})); + EXPECT_EQ(model.conflictCountOf(watchIdOf("watch")), 3u); + EXPECT_EQ(model.conflictCount(), 3u); } - TEST(TrackingModelTests, TheFailuresOfAWatchStartWithTheNewestTiesByTheGreaterId) + TEST(TrackingModelTests, TheFailuresOfAWatchStartWithTheLastShown) { using namespace TrackingModelTestDetail; TrackingModel model; @@ -699,10 +749,178 @@ namespace FileTracking::Test model.showFailure(failureOf("charlie", "watch", 100u)); model.showFailure(failureOf("delta", "other", 500u)); - EXPECT_EQ(failureNamesOf(model.failuresOf(watchIdOf("watch"))), (Names{"bravo", "charlie", "alpha"})); + EXPECT_EQ(failureNamesOf(allFailuresOf(model, "watch")), (Names{"charlie", "bravo", "alpha"})); + EXPECT_EQ(model.failureCountOf(watchIdOf("watch")), 3u); + + auto repeated = failureOf("alpha", "watch", 600u); + repeated.attempts = 2u; + model.showFailure(repeated); + + EXPECT_EQ(failureNamesOf(allFailuresOf(model, "watch")), (Names{"alpha", "charlie", "bravo"})); EXPECT_EQ(model.failureCountOf(watchIdOf("watch")), 3u); } + TEST(TrackingModelTests, PagesAreSlicesOfTheConflictsOfOneWatch) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showEntry(watchEntry("other", "other")); + showConflicts(model, "conflict", "watch", 250); + showConflicts(model, "other", "other", 3); + + const auto first = model.conflictsOf(watchIdOf("watch"), 0u, 100u); + ASSERT_EQ(first.size(), 100u); + EXPECT_EQ(first.front().conflictId, Ids::makeConflictId("conflict-249")); + EXPECT_EQ(first.back().conflictId, Ids::makeConflictId("conflict-150")); + + const auto last = model.conflictsOf(watchIdOf("watch"), 200u, 100u); + ASSERT_EQ(last.size(), 50u); + EXPECT_EQ(last.front().conflictId, Ids::makeConflictId("conflict-49")); + EXPECT_EQ(last.back().conflictId, Ids::makeConflictId("conflict-0")); + + EXPECT_TRUE(model.conflictsOf(watchIdOf("watch"), 250u, 100u).empty()); + EXPECT_TRUE(model.conflictsOf(watchIdOf("unknown"), 0u, 100u).empty()); + EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("other"), 1u, 100u)), (Names{"other-1", "other-0"})); + } + + TEST(TrackingModelTests, PagesAreSlicesOfTheFailuresOfOneWatch) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + showFailures(model, "failure", "watch", 120); + showFailures(model, "other", "other", 5); + + const auto second = model.failuresOf(watchIdOf("watch"), 100u, 100u); + ASSERT_EQ(second.size(), 20u); + EXPECT_EQ(second.front().failureId, Ids::makeFailureId("failure-19")); + EXPECT_EQ(second.back().failureId, Ids::makeFailureId("failure-0")); + EXPECT_EQ(model.failureCountOf(watchIdOf("watch")), 120u); + EXPECT_EQ(model.failureCountOf(watchIdOf("other")), 5u); + EXPECT_EQ(model.failureCount(), 120u); + } + + TEST(TrackingModelTests, RemovingManyConflictsAcrossWatchesUpdatesEveryCount) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "first")); + model.showEntry(watchEntry("second", "second")); + showConflicts(model, "first", "first", 4); + showConflicts(model, "second", "second", 3); + showConflicts(model, "unknown", "unknown", 2); + + model.removeConflicts(conflictIdsOf({"first-1", "second-0", "unknown-0", "first-3", "missing", "first-1"})); + + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "first")), (Names{"first-2", "first-0"})); + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "second")), (Names{"second-2", "second-1"})); + EXPECT_EQ(model.conflictCountOf(watchIdOf("unknown")), 1u); + EXPECT_EQ(model.conflictCount(), 4u); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("first-1")), std::nullopt); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("unknown-0")), std::nullopt); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("first-2")), watchIdOf("first")); + + model.removeConflicts(conflictIdsOf({"second-2", "second-1"})); + + EXPECT_EQ(model.conflictCountOf(watchIdOf("second")), 0u); + EXPECT_EQ(model.statusOf(watchIdOf("second")), WatchStatus::Watching); + EXPECT_EQ(model.conflictCount(), 2u); + } + + TEST(TrackingModelTests, RemovingManyFailuresAcrossWatchesUpdatesEveryCount) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("first", "first")); + model.showEntry(watchEntry("second", "second")); + showFailures(model, "first", "first", 3); + showFailures(model, "second", "second", 2); + + model.removeFailures(failureIdsOf({"first-0", "second-1", "first-2", "missing"})); + + EXPECT_EQ(failureNamesOf(allFailuresOf(model, "first")), (Names{"first-1"})); + EXPECT_EQ(failureNamesOf(allFailuresOf(model, "second")), (Names{"second-0"})); + EXPECT_EQ(model.failureCount(), 2u); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("first-0")), std::nullopt); + + model.removeFailures({}); + + EXPECT_EQ(model.failureCount(), 2u); + } + + TEST(TrackingModelTests, RemovingAWatchKeepsTheProblemsOfTheOthers) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("removed", "removed")); + model.showEntry(watchEntry("kept", "kept")); + showConflicts(model, "removed", "removed", 150); + showFailures(model, "removed", "removed", 150); + showConflicts(model, "kept", "kept", 2); + showFailures(model, "kept", "kept", 3); + model.removeEntry(watchIdOf("removed")); + + EXPECT_EQ(model.conflictCount(), 2u); + EXPECT_EQ(model.failureCount(), 3u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("removed")), 0u); + EXPECT_EQ(model.failureCountOf(watchIdOf("removed")), 0u); + EXPECT_TRUE(model.conflictsOf(watchIdOf("removed"), 0u, 100u).empty()); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("removed-7")), std::nullopt); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("removed-7")), std::nullopt); + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "kept")), (Names{"kept-1", "kept-0"})); + EXPECT_EQ(model.watchOfFailure(Ids::makeFailureId("kept-2")), watchIdOf("kept")); + + model.showConflict(conflictOf("removed-7", "removed", 100u)); + + EXPECT_EQ(model.conflictCountOf(watchIdOf("removed")), 1u); + EXPECT_EQ(model.conflictCount(), 2u); + } + + TEST(TrackingModelTests, TheTotalsFollowWatchesThatArriveAfterTheirProblems) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + showConflicts(model, "late", "late", 3); + showFailures(model, "late", "late", 2); + + EXPECT_EQ(model.conflictCount(), 0u); + EXPECT_EQ(model.failureCount(), 0u); + + model.showEntry(watchEntry("late", "late")); + model.showEntry(watchEntry("late", "late renamed")); + + EXPECT_EQ(model.conflictCount(), 3u); + EXPECT_EQ(model.failureCount(), 2u); + + model.removeConflicts(conflictIdsOf({"late-0"})); + model.removeEntry(watchIdOf("late")); + + EXPECT_EQ(model.conflictCount(), 0u); + EXPECT_EQ(model.failureCount(), 0u); + } + + TEST(TrackingModelTests, AConflictMovedToAnotherWatchCountsThere) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("known", "known")); + model.showConflict(conflictOf("moving", "known", 100u)); + model.showConflict(conflictOf("staying", "known", 100u)); + model.showConflict(conflictOf("moving", "unknown", 200u)); + + EXPECT_EQ(model.conflictCount(), 1u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("known")), 1u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("unknown")), 1u); + EXPECT_EQ(model.watchOfConflict(Ids::makeConflictId("moving")), watchIdOf("unknown")); + + model.showConflict(conflictOf("moving", "known", 300u)); + + EXPECT_EQ(model.conflictCount(), 2u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("unknown")), 0u); + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "known")), (Names{"moving", "staying"})); + } + TEST(TrackingModelTests, TheTotalsCountEveryKnownWatch) { using namespace TrackingModelTestDetail; @@ -720,28 +938,29 @@ namespace FileTracking::Test EXPECT_EQ(model.failureCount(), 1u); } - TEST(TrackingModelTests, TheNewestConflictHasTheLatestRaisedAt) + TEST(TrackingModelTests, TheNewestConflictIsTheLastShownOfAKnownWatch) { using namespace TrackingModelTestDetail; TrackingModel model; model.showEntry(watchEntry("first", "first.txt")); model.showEntry(watchEntry("second", "second.txt")); model.showConflict(conflictOf("older", "first", 100u)); - model.showConflict(conflictOf("newest", "second", 300u)); - model.showConflict(conflictOf("newer", "first", 200u)); + model.showConflict(conflictOf("newer", "first", 50u)); + model.showConflict(conflictOf("newest", "second", 10u)); model.showConflict(conflictOf("unknown", "unknown", 900u)); ASSERT_TRUE(model.newestConflict().has_value()); EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("newest")); EXPECT_EQ(model.newestConflict()->watchId, watchIdOf("second")); - model.removeConflict(Ids::makeConflictId("newest")); + model.removeConflicts({Ids::makeConflictId("newest")}); ASSERT_TRUE(model.newestConflict().has_value()); EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("newer")); + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "first")).front(), "newer"); } - TEST(TrackingModelTests, NewestConflictTiesGoToTheGreaterId) + TEST(TrackingModelTests, AConflictShownAgainBecomesTheNewest) { using namespace TrackingModelTestDetail; TrackingModel model; @@ -749,10 +968,10 @@ namespace FileTracking::Test model.showEntry(watchEntry("second", "second.txt")); model.showConflict(conflictOf("bravo", "first", 100u)); model.showConflict(conflictOf("alpha", "second", 100u)); + model.showConflict(conflictOf("bravo", "first", 100u)); ASSERT_TRUE(model.newestConflict().has_value()); EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("bravo")); - EXPECT_EQ(conflictNamesOf(model.conflictsOf(watchIdOf("first"))).front(), "bravo"); } TEST(TrackingModelTests, NoConflictsMeanNoNewestConflict) @@ -764,7 +983,7 @@ namespace FileTracking::Test model.showEntry(watchEntry("watch", "notes.txt")); model.showConflict(conflictOf("conflict", "watch", 100u)); - model.removeConflict(Ids::makeConflictId("conflict")); + model.removeConflicts({Ids::makeConflictId("conflict")}); EXPECT_FALSE(model.newestConflict().has_value()); } From 19f7db5923f5b31655beecaa410ab2c9647a0805 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 22:12:50 +0200 Subject: [PATCH 105/114] Paged the Conflicts and Failed tabs with the library pagination bar --- .../file_tracking/conflicts_tab.hpp | 7 +- .../file_tracking/failures_tab.hpp | 10 +-- .../file_tracking/item_list_box.hpp | 25 +++++-- .../file_tracking/tracking_actions.hpp | 4 ++ .../session_components/file_tracking.cpp | 10 +++ .../file_tracking/conflicts_tab.cpp | 9 +++ .../file_tracking/failures_tab.cpp | 9 +++ .../file_tracking/item_list_box.cpp | 66 ++++++++++++++++++- static/styles/file_tracking_tabs.css | 23 ++++++- 9 files changed, 149 insertions(+), 14 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp index 3eb94c65..2ab64d90 100644 --- a/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp +++ b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp @@ -13,9 +13,10 @@ namespace FileTracking /** * @brief The Conflicts tab: an itemListBox counting item->conflictCount, with Replace All on Server * (data-ft-action=replace-all, Warning) and Keep All Server's (keep-all) calling actions.answerAllConflicts, - * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts (data-ft-conflict-id, - * data-ft-kind=, data-ft-path=): the file, the line, Replace on Server (replace, - * Warning) and Keep Server's (keep) calling actions.answerConflict. + * the hint keepAsksAgain and the empty text noConflicts. One row per item->conflicts, the page shown + * (data-ft-conflict-id, data-ft-kind=, data-ft-path=): the file, the line, Replace + * on Server (replace, Warning) and Keep Server's (keep) calling actions.answerConflict. The pagination bar + * below follows item->conflictPage and item->conflictPageCount and calls actions.showConflictPage. */ Nui::ElementRenderer conflictsTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp index 4a10b06a..257be1a3 100644 --- a/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp +++ b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp @@ -13,10 +13,12 @@ namespace FileTracking /** * @brief The Failed tab: an itemListBox counting item->failureCount, with Retry All (data-ft-action=retry-all, * Primary) and Dismiss All (dismiss-all), the hint notRetriedAutomatically and the empty text noFailures. - * One row per item->failures (data-ft-failure-id, data-ft-reason=, data-ft-attempts, - * data-ft-path=): the file, the reason (its title the detail when there is one, plus - * failedTimes after more than one attempt), the time, Retry (retry, actions.retryFailure) and a dismiss - * icon button (dismiss, title dismiss, aria-label dismissFailureLabel, actions.dismissFailure). + * One row per item->failures, the page shown (data-ft-failure-id, data-ft-reason=, + * data-ft-attempts, data-ft-path=): the file, the reason (its title the detail when there is + * one, plus failedTimes after more than one attempt), the time, Retry (retry, actions.retryFailure) and a + * dismiss icon button (dismiss, title dismiss, aria-label dismissFailureLabel, actions.dismissFailure). The + * pagination bar below follows item->failurePage and item->failurePageCount and calls + * actions.showFailurePage. */ Nui::ElementRenderer failuresTab(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item); diff --git a/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp index 4f841f19..1c427eec 100644 --- a/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp +++ b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp @@ -14,6 +14,19 @@ namespace FileTracking { + /** + * @brief The pages of a list too long for one, for @ref itemListBox. Both observed values are required. + */ + struct ItemListBoxPaging + { + /** @brief The page shown, 0-based, the value of data-ft-page. */ + std::shared_ptr> page = {}; + /** @brief The number of pages, the value of data-ft-page-count. The bar shows nothing for one page. */ + std::shared_ptr> pageCount = {}; + /** @brief Gets the page the user chose. */ + std::function onPageChange = {}; + }; + /** * @brief The options of @ref itemListBox, for designated initializers in this order. Every field has a default. */ @@ -27,16 +40,20 @@ namespace FileTracking std::string emptyText = {}; /** @brief The number of items, data-empty is on the root while it is 0. A null pointer counts as not empty. */ std::shared_ptr> count = {}; + /** @brief A pagination bar below the box, staying in place while the rows scroll. */ + std::optional paging = std::nullopt; /** @brief Merged onto the root, for example data-class="conflicts" or "failures" for the box colours. */ std::vector attributes = {}; }; /** * @brief A toolbar above a box that scrolls on its own, or an empty text instead of both. Markup: - * div.file-tracking-item-list-box[data-empty] > div.file-tracking-item-list-box-toolbar - * (div.file-tracking-item-list-box-actions holding the actions, then span.file-tracking-item-list-box-hint), - * div.file-tracking-item-list-box-items (@p content) and div.file-tracking-item-list-box-empty (emptyText). - * Kept in the host until a second user (the queue's Failed tab) adopts it. + * div.file-tracking-item-list-box[data-empty][data-ft-page][data-ft-page-count] > + * div.file-tracking-item-list-box-toolbar (div.file-tracking-item-list-box-actions holding the actions, then + * span.file-tracking-item-list-box-hint), div.file-tracking-item-list-box-items (@p content), + * div.file-tracking-item-list-box-pagination (with paging only: the pagination bar, a page change scrolls + * the items to the top) and div.file-tracking-item-list-box-empty (emptyText). The page attributes are set + * with paging only. Kept in the host until a second user (the queue's Failed tab) adopts it. * @param content The rows, usually an element holding a Nui::range. */ Nui::ElementRenderer itemListBox(ItemListBoxOptions options, Nui::ElementRenderer content); diff --git a/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp index 3776c700..2fdeceb0 100644 --- a/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp +++ b/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp @@ -32,6 +32,10 @@ namespace FileTracking std::function retryAllFailures{}; /** @brief Dismiss All for the failures of a watch. */ std::function dismissAllFailures{}; + /** @brief A page of the Conflicts tab of a watch was chosen, 0-based. */ + std::function showConflictPage{}; + /** @brief A page of the Failed tab of a watch was chosen, 0-based. */ + std::function showFailurePage{}; /** @brief A switch of the Options tab changed. */ std::function setOption{}; /** @brief Stop on a running watch; it stays listed under Stopped. */ diff --git a/frontend/source/frontend/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index 62645764..ded49ad2 100644 --- a/frontend/source/frontend/session_components/file_tracking.cpp +++ b/frontend/source/frontend/session_components/file_tracking.cpp @@ -240,6 +240,16 @@ struct FileTrackingPanel::Implementation { client.dismissAllFailures(watchId); }, + .showConflictPage = + [this](Ids::WatchId const& watchId, int page) + { + store.setConflictPage(watchId, page); + }, + .showFailurePage = + [this](Ids::WatchId const& watchId, int page) + { + store.setFailurePage(watchId, page); + }, .setOption = [this](Ids::WatchId const& watchId, SharedData::WatchOption option, bool enabled) { diff --git a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp index 2c4883af..dbdae21e 100644 --- a/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp @@ -101,6 +101,15 @@ namespace FileTracking .hint = language->get("fileTracking", "keepAsksAgain"), .emptyText = language->get("fileTracking", "noConflicts"), .count = item->conflictCount, + .paging = ItemListBoxPaging{ + .page = item->conflictPage, + .pageCount = item->conflictPageCount, + .onPageChange = [&actions, watchId = item->watchId](int page) + { + if (actions.showConflictPage) + actions.showConflictPage(watchId, page); + }, + }, .attributes = {"data-class"_attr = "conflicts"}, }, div{class_ = "file-tracking-item-list", role = "list"}( diff --git a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp index 4bbae230..3de80272 100644 --- a/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp +++ b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp @@ -133,6 +133,15 @@ namespace FileTracking .hint = language->get("fileTracking", "notRetriedAutomatically"), .emptyText = language->get("fileTracking", "noFailures"), .count = item->failureCount, + .paging = ItemListBoxPaging{ + .page = item->failurePage, + .pageCount = item->failurePageCount, + .onPageChange = [&actions, watchId](int page) + { + if (actions.showFailurePage) + actions.showFailurePage(watchId, page); + }, + }, .attributes = {"data-class"_attr = "failures"}, }, div{class_ = "file-tracking-item-list", role = "list"}( diff --git a/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp index 141b5779..60340bf4 100644 --- a/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp +++ b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp @@ -1,17 +1,58 @@ #include #include +#include #include #include +#include #include #include #include +#include #include namespace FileTracking { + namespace + { + using ElementHandle = std::shared_ptr>; + + /** + * @brief The pagination bar of @p paging. Choosing another page scrolls @p items back to the top. + */ + Nui::ElementRenderer paginationOf(ItemListBoxPaging paging, ElementHandle items) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + auto* const page = paging.page.get(); + auto* const pageCount = paging.pageCount.get(); + + // clang-format off + return div{class_ = "file-tracking-item-list-box-pagination"}( + ScriptNuiComponents::pagination({ + .pageCount = pageCount, + .currentPage = page, + .onPageChange = [paging = std::move(paging), items = std::move(items)](int chosen) + { + if (chosen != paging.page->value()) + { + if (const auto element = items->lock()) + element->val().set("scrollTop", 0); + } + if (paging.onPageChange) + paging.onPageChange(chosen); + }, + .liveLabel = std::nullopt, + }) + ); + // clang-format on + } + } + Nui::ElementRenderer itemListBox(ItemListBoxOptions options, Nui::ElementRenderer content) { using namespace Nui::Elements; @@ -19,8 +60,11 @@ namespace FileTracking using Nui::Elements::div; using Nui::Elements::span; + const auto paged = options.paging && options.paging->page && options.paging->pageCount; + const auto items = std::make_shared>(); + std::vector rootAttributes{}; - rootAttributes.reserve(2u); + rootAttributes.reserve(4u); rootAttributes.push_back(class_ = "file-tracking-item-list-box"); if (options.count) { @@ -34,6 +78,17 @@ namespace FileTracking ) ); } + if (paged) + { + const auto asText = [](int number) + { + return std::to_string(number); + }; + rootAttributes.push_back("data-ft-page"_attr = Nui::observe(options.paging->page).generate(asText)); + rootAttributes.push_back( + "data-ft-page-count"_attr = Nui::observe(options.paging->pageCount).generate(asText) + ); + } // clang-format off return div{Nui::mergeAttributes(std::move(rootAttributes), std::move(options.attributes))}( @@ -43,9 +98,16 @@ namespace FileTracking ), span{class_ = "file-tracking-item-list-box-hint"}(std::move(options.hint)) ), - div{class_ = "file-tracking-item-list-box-items"}( + div{ + class_ = "file-tracking-item-list-box-items", + reference = [items](std::weak_ptr&& element) + { + *items = std::move(element); + }, + }( std::move(content) ), + paged ? paginationOf(std::move(*options.paging), items) : Nui::nil(), div{class_ = "file-tracking-item-list-box-empty"}(std::move(options.emptyText)) ); // clang-format on diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css index cf9d3516..9237a42b 100644 --- a/static/styles/file_tracking_tabs.css +++ b/static/styles/file_tracking_tabs.css @@ -59,7 +59,18 @@ &[data-empty] > .file-tracking-item-list-box-toolbar, &[data-empty] > .file-tracking-item-list-box-items, - &:not([data-empty]) > .file-tracking-item-list-box-empty { + &[data-empty] > .file-tracking-item-list-box-pagination, + &:not([data-empty]) > .file-tracking-item-list-box-pagination { + display: flex; + flex-shrink: 0; + justify-content: center; + + &:not(:has(.script-nui-pagination-bar)) { + display: none; + } +} + +.file-tracking-item-list-box-empty { display: none; } @@ -110,6 +121,16 @@ background: color-mix(in srgb, var(--file-tracking-item-list-box-accent) 7%, var(--brightness-mixin) 93%); } +.file-tracking-item-list-box-pagination { + display: flex; + flex-shrink: 0; + justify-content: center; + + &:not(:has(.script-nui-pagination-bar)) { + display: none; + } +} + .file-tracking-item-list-box-empty { padding-top: 6px; color: var(--color-secondary); From 35abe7d7b8530015ffc8232b49954780bd2eb035 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 22:13:55 +0200 Subject: [PATCH 106/114] Named the problem list iterator type in camel case --- .../session_components/file_tracking/problem_list.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/include/frontend/session_components/file_tracking/problem_list.hpp b/frontend/include/frontend/session_components/file_tracking/problem_list.hpp index 468e6907..e78e8827 100644 --- a/frontend/include/frontend/session_components/file_tracking/problem_list.hpp +++ b/frontend/include/frontend/session_components/file_tracking/problem_list.hpp @@ -32,7 +32,7 @@ namespace FileTracking ValueT value; }; - using const_iterator = typename std::deque::const_iterator; + using ConstIterator = typename std::deque::const_iterator; /** * @brief Shows @p value under @p id, replacing what @p id held and moving it to its place by @p sequence. @@ -178,7 +178,7 @@ namespace FileTracking /** * @return The first problem, the newest, for iterating newest first. */ - const_iterator begin() const + ConstIterator begin() const { return problems_.cbegin(); } @@ -186,7 +186,7 @@ namespace FileTracking /** * @return Past the oldest problem. */ - const_iterator end() const + ConstIterator end() const { return problems_.cend(); } From 57a4c58a6e863dfe08fb99e5b2bfbdaaeffc59a1 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sat, 24 Oct 2026 22:57:06 +0200 Subject: [PATCH 107/114] Synced the file tracking panel after every tracker event again - Coalescing the syncs of a burst crashed the page: Nui's range tracking lost its place when erase and insert pairs of the page rows piled up between syncs (Element::erase past the end of the children) - A page holds at most 100 rows, so one sync per event stays cheap: 2,000 failures list in 5 s, 10,000 in 44 s --- .../file_tracking/tracker_client.cpp | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp index d34c2cfc..9f11987c 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp @@ -11,7 +11,6 @@ #include #include -#include #include #include @@ -35,8 +34,6 @@ namespace FileTracking std::function onEnded; Ids::SessionId sessionId{}; std::vector listeners{}; - /** @brief Set from the first event after a sync until the sync it scheduled ran. */ - bool syncScheduled{false}; Utility::LifetimeToken lifetime{}; /** @@ -47,27 +44,6 @@ namespace FileTracking Nui::globalEventContext.executeActiveEventsImmediately(); } - /** - * @brief Shows what the events since the last sync changed from a task of its own, so a burst of events syncs - * once. Events arriving before that task ran schedule nothing more. - */ - void scheduleSync() - { - if (syncScheduled) - return; - syncScheduled = true; - Nui::val::global("setTimeout")( - Nui::bind(lifetime.guarded( - [this]() - { - syncScheduled = false; - syncView(); - } - )), - Nui::val{0} - ); - } - static bool succeeded(Nui::val const& response) { return response.hasOwnProperty("success") && response["success"].as(); @@ -106,7 +82,9 @@ namespace FileTracking } /** - * @brief Registers @p event of the tracker, @p handler gets its payload and a sync is scheduled after it. + * @brief Registers @p event of the tracker, @p handler gets its payload and the view syncs once after it. Not + * coalesced over a burst: Nui's range tracking loses its place when erase and insert pairs of the page + * rows pile up between syncs, and a page holds at most problemPageSize rows, so a sync stays cheap. */ template void listenTo(std::string_view event, HandlerT handler) @@ -115,10 +93,10 @@ namespace FileTracking Nui::RpcClient::autoRegisterFunction( rpcName(event), lifetime.guarded( - [this, handler = std::move(handler)](PayloadT const& payload) + [handler = std::move(handler)](PayloadT const& payload) { handler(payload); - scheduleSync(); + syncView(); } ) ) From 7ba1a5432e7a57dfea680272bfd06a6ac99a5c6e Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 01:30:06 +0200 Subject: [PATCH 108/114] Updated Nui to v3.7.3 with the range tracking fixes --- dependencies/Nui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/Nui b/dependencies/Nui index 888aab5d..71094b30 160000 --- a/dependencies/Nui +++ b/dependencies/Nui @@ -1 +1 @@ -Subproject commit 888aab5d8ac3592bec68af9edd2b61d9cec80aff +Subproject commit 71094b3059295d547d36bea03a04a25d564ed105 From df641b2b71496447f59195298938d63167fa2f44 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 01:30:17 +0200 Subject: [PATCH 109/114] Synced the file tracking panel once per event burst again, fixed in Nui v3.7.3 Reverts 57a4c58a6: Nui v3.7.3 tracks erases over pending inserts correctly, so erase and insert pairs may pile up between syncs again. --- .../file_tracking/tracker_client.cpp | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp index 9f11987c..d34c2cfc 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -34,6 +35,8 @@ namespace FileTracking std::function onEnded; Ids::SessionId sessionId{}; std::vector listeners{}; + /** @brief Set from the first event after a sync until the sync it scheduled ran. */ + bool syncScheduled{false}; Utility::LifetimeToken lifetime{}; /** @@ -44,6 +47,27 @@ namespace FileTracking Nui::globalEventContext.executeActiveEventsImmediately(); } + /** + * @brief Shows what the events since the last sync changed from a task of its own, so a burst of events syncs + * once. Events arriving before that task ran schedule nothing more. + */ + void scheduleSync() + { + if (syncScheduled) + return; + syncScheduled = true; + Nui::val::global("setTimeout")( + Nui::bind(lifetime.guarded( + [this]() + { + syncScheduled = false; + syncView(); + } + )), + Nui::val{0} + ); + } + static bool succeeded(Nui::val const& response) { return response.hasOwnProperty("success") && response["success"].as(); @@ -82,9 +106,7 @@ namespace FileTracking } /** - * @brief Registers @p event of the tracker, @p handler gets its payload and the view syncs once after it. Not - * coalesced over a burst: Nui's range tracking loses its place when erase and insert pairs of the page - * rows pile up between syncs, and a page holds at most problemPageSize rows, so a sync stays cheap. + * @brief Registers @p event of the tracker, @p handler gets its payload and a sync is scheduled after it. */ template void listenTo(std::string_view event, HandlerT handler) @@ -93,10 +115,10 @@ namespace FileTracking Nui::RpcClient::autoRegisterFunction( rpcName(event), lifetime.guarded( - [handler = std::move(handler)](PayloadT const& payload) + [this, handler = std::move(handler)](PayloadT const& payload) { handler(payload); - syncView(); + scheduleSync(); } ) ) From cb279f51f108a955bf75cb4a1f7ad22501cb004d Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 02:42:13 +0200 Subject: [PATCH 110/114] Updated the bulk entry progress by the entries in flight, not every entry --- .../file_tracking/bulk_entry_progress.hpp | 7 ++ .../file_tracking/bulk_entry_progress.cpp | 72 ++++++++++++------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/backend/include/backend/file_tracking/bulk_entry_progress.hpp b/backend/include/backend/file_tracking/bulk_entry_progress.hpp index cd3bfefa..6c74bb45 100644 --- a/backend/include/backend/file_tracking/bulk_entry_progress.hpp +++ b/backend/include/backend/file_tracking/bulk_entry_progress.hpp @@ -63,6 +63,10 @@ namespace FileTracking File file; /** @brief Seen on its way, it is done once it is no longer. */ bool started{false}; + /** @brief Listed among the bulk's failed entries. */ + bool leftOut{false}; + /** @brief The last update that saw it on its way, 0 for none. */ + std::uint64_t onItsWayIn{0u}; std::optional told{}; }; @@ -81,6 +85,9 @@ namespace FileTracking private: std::vector entries_; std::optional directory_; + /** @brief The entries seen on their way that were not told done yet, by index. */ + std::vector started_{}; + std::uint64_t updates_{0u}; }; /** diff --git a/backend/source/backend/file_tracking/bulk_entry_progress.cpp b/backend/source/backend/file_tracking/bulk_entry_progress.cpp index d03df1ab..52940a0a 100644 --- a/backend/source/backend/file_tracking/bulk_entry_progress.cpp +++ b/backend/source/backend/file_tracking/bulk_entry_progress.cpp @@ -1,8 +1,6 @@ #include #include -#include -#include #include namespace FileTracking @@ -88,34 +86,56 @@ namespace FileTracking std::vector BulkEntryProgress::updateFiles(SharedData::BulkProgress const& progress) { - const std::set leftOutIndices( - progress.failedEntryIndices.begin(), progress.failedEntryIndices.end() - ); - std::map onTheirWay; - for (auto const& file : progress.inFlight) - onTheirWay.emplace(file.entryIndex, &file); - // Every entry ended, those that were never seen on their way too. - const bool through = progress.fileCount > 0u && progress.fileCurrentIndex >= progress.fileCount; - + const auto update = ++updates_; std::vector changed; - for (std::size_t index = 0; index < entries_.size(); ++index) + for (auto const& file : progress.inFlight) + { + const auto index = static_cast(file.entryIndex); + if (index >= entries_.size() || entries_[index].onItsWayIn == update) + continue; + auto& entry = entries_[index]; + if (!std::exchange(entry.started, true)) + started_.push_back(index); + entry.onItsWayIn = update; + entry.file.sizeBytes = file.totalBytes; + tellChanged(entry.told, index, onItsWay(entry.file.source, file.bytes, entry.file.sizeBytes), changed); + } + for (const auto failedIndex : progress.failedEntryIndices) { + const auto index = static_cast(failedIndex); + if (index >= entries_.size() || entries_[index].onItsWayIn == update) + continue; auto& entry = entries_[index]; - if (const auto found = onTheirWay.find(index); found != onTheirWay.end()) - { - entry.started = true; - entry.file.sizeBytes = found->second->totalBytes; - tellChanged( - entry.told, index, onItsWay(entry.file.source, found->second->bytes, entry.file.sizeBytes), changed - ); - } - else if (leftOutIndices.contains(index)) - tellChanged( - entry.told, index, leftOut(entry.told ? entry.told->bytesSent : 0u, entry.file.sizeBytes), changed - ); - else if (entry.started || through) - tellChanged(entry.told, index, done(entry.file.sizeBytes), changed); + entry.leftOut = true; + tellChanged( + entry.told, index, leftOut(entry.told ? entry.told->bytesSent : 0u, entry.file.sizeBytes), changed + ); + } + const auto isDone = [this, update](std::size_t index) + { + auto const& entry = entries_[index]; + return entry.onItsWayIn != update && !entry.leftOut; + }; + // Every entry ended, those that were never seen on their way too. + if (progress.fileCount > 0u && progress.fileCurrentIndex >= progress.fileCount) + { + for (std::size_t index = 0; index < entries_.size(); ++index) + if (isDone(index)) + tellChanged(entries_[index].told, index, done(entries_[index].file.sizeBytes), changed); } + else + std::erase_if( + started_, + [this, update, &isDone, &changed](std::size_t index) + { + if (entries_[index].onItsWayIn == update) + return false; + if (isDone(index)) + tellChanged(entries_[index].told, index, done(entries_[index].file.sizeBytes), changed); + return true; + } + ); + std::ranges::sort(changed, {}, &EntryProgress::index); return changed; } From 55c252c64c796edc21fb0d3b1dd9c4a8f29bef89 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 02:42:13 +0200 Subject: [PATCH 111/114] Handed tracked upload progress to the queue without an extra end guard --- .../source/backend/session_file_tracking.cpp | 65 ++++--------------- 1 file changed, 12 insertions(+), 53 deletions(-) diff --git a/backend/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index 2b934a5d..d39907bf 100644 --- a/backend/source/backend/session_file_tracking.cpp +++ b/backend/source/backend/session_file_tracking.cpp @@ -157,45 +157,6 @@ namespace return std::filesystem::is_directory(std::filesystem::symlink_status(path.native(), statusError)); } - /** - * @brief The progress observer and the completion callback of one queued operation, the observer stays silent - * once the operation completed. - */ - struct ProgressUntilEnd - { - OperationQueue::ProgressObserver observer; - std::function onCompleted; - }; - - /** - * @brief Hands the progress of an operation to @p observer until @p onCompleted hears it completed, both on the - * strand. The observer is empty for an empty @p observer. - */ - ProgressUntilEnd progressUntilEnd( - OperationQueue::ProgressObserver observer, - std::function onCompleted - ) - { - const auto ended = std::make_shared(false); - ProgressUntilEnd result{ - .observer = {}, - .onCompleted = - [ended, onCompleted = std::move(onCompleted)](SharedData::OperationCompleted const& completed) - { - *ended = true; - if (onCompleted) - onCompleted(completed); - }, - }; - if (observer) - result.observer = [ended, observer = std::move(observer)](OperationQueue::OperationProgress const& progress) - { - if (!*ended) - observer(progress); - }; - return result; - } - /** * @brief Tells @p onProgress how far the single upload of @p request is, that of a directory by its entries. */ @@ -329,15 +290,12 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote { const bool isDirectory = isDirectoryLocally(request.source); const auto sent = std::make_shared(); - auto progressing = progressUntilEnd( - onProgress ? singleUploadProgress(request, std::move(onProgress)) : OperationQueue::ProgressObserver{}, - forgettingSentUpload( - request.destination, endOfUpload(session_, request, isDirectory, sent, std::move(onEnded)) - ) - ); + // The queue drops the observer once the upload completed, before its end is handed on. + auto observer = + onProgress ? singleUploadProgress(request, std::move(onProgress)) : OperationQueue::ProgressObserver{}; std::optional queuedAs{}; auto queued = queue( - [&request, &sent, &queuedAs, &observer = progressing.observer]( + [&request, &sent, &queuedAs, &observer]( OperationQueue& queue, SecureShell::SftpSession& channel, Ids::OperationId const& operationId ) { @@ -368,7 +326,9 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote } return added; }, - std::move(progressing.onCompleted) + forgettingSentUpload( + request.destination, endOfUpload(session_, request, isDirectory, sent, std::move(onEnded)) + ) ); if (queued && queuedAs) sentUploads_->insert_or_assign(request.destination.value(), *queuedAs); @@ -500,9 +460,11 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote // The tracker decides anew what goes once the session is back. queue.discardResumeBackup(cardId); queue.leaveChangedDestinationsToOwner(cardId); - auto progressing = progressUntilEnd( - onProgress ? bulkUploadProgress(std::move(files), std::move(onProgress)) - : OperationQueue::ProgressObserver{}, + // The queue drops the observer once the bulk completed, before its ends are handed on. + if (onProgress) + queue.whenProgressing(cardId, bulkUploadProgress(std::move(files), std::move(onProgress))); + queue.whenCompleted( + cardId, [session = session.weak_from_this(), onEnded = std::move(onEnded), sent, @@ -514,9 +476,6 @@ class Session::FileTrackingRemote : public FileTracking::BulkTrackerRemote endUploads(session, completed, sources, destinations, sent->take(), onEnded); } ); - if (progressing.observer) - queue.whenProgressing(cardId, std::move(progressing.observer)); - queue.whenCompleted(cardId, std::move(progressing.onCompleted)); session.resetQueueThrottle(); return {}; } From 199b20337014aad47a8841249b7115dd5012cc24 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 02:42:13 +0200 Subject: [PATCH 112/114] Refreshed the orphan items only when an entry starts or stops holding one --- .../session_components/file_tracking/tracking_store.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp index 24d238dd..af762a26 100644 --- a/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp +++ b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp @@ -598,7 +598,8 @@ namespace FileTracking impl_->refreshWatch(entry.watchId, inserted ? everyPart : WatchParts{.entry = true}); if (inserted && !entry.stop && std::holds_alternative(impl_->selection.value())) impl_->selection = Selection{entry.watchId}; - if (heldInstance || entry.instanceId) + // Only an entry that starts or stops holding an instance changes which copies are orphans. + if (heldInstance != entry.instanceId.has_value()) impl_->refreshOrphans(); impl_->notifyAttention(); } From 7ff0c5cadbedbcd85bae9bd01b323d60fa4aee1b Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 02:42:13 +0200 Subject: [PATCH 113/114] Made the tracker's progress, orphan list and entry display cheaper and safer - Count pending changes on the progress show during a burst, not on every event - Keep the orphan list in the instance manager, rescanned when the panel asks - Tell upload generations apart in the progress meter so a late end keeps the new one - Take isDirectory from the local watch instead of a stat per shown entry - Carry the download size in the entry so Open Again keeps the big-file path --- .../temp_dir_instance_manager.hpp | 19 ++++- .../include/backend/file_tracking/tracker.hpp | 4 +- .../file_tracking/watch_progress_meter.hpp | 28 +++++-- .../temp_dir_instance_manager.cpp | 61 +++++++++++++- .../source/backend/file_tracking/tracker.cpp | 81 ++++++++++++++----- .../file_tracking/watch_progress_meter.cpp | 39 +++++++-- .../backend/test_watch_progress_meter.hpp | 18 +++++ .../session_components/file_tracking.cpp | 2 +- .../file_tracking/tracked_entry.hpp | 5 +- 9 files changed, 216 insertions(+), 41 deletions(-) diff --git a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp index 6dfa153f..24b5bb77 100644 --- a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -157,10 +158,16 @@ namespace FileTracking /** * @brief The instance directories below the root that no live process holds, with what they hold and where * it came from, sorted by instance id. The orphan test is the one of countOrphaned(). An instance that - * holds nothing has an empty name and no copy path. + * holds nothing has an empty name and no copy path. Scanned once, then kept up to date with what this + * manager changes, see rescanOrphaned(). */ std::vector listOrphaned() const; + /** + * @brief Scans the orphans anew on the next listOrphaned(), for what other processes changed meanwhile. + */ + void rescanOrphaned(); + /** * @brief Removes the directory of the orphaned instance @p instanceId with everything in it. * @return An error when @p instanceId is no plain directory name, names no directory below the root, the @@ -196,10 +203,20 @@ namespace FileTracking */ void manualCleanup(); + private: + std::vector scanOrphaned() const; + + /** + * @brief Adds the instance directory @p directory to the kept orphans when it is one now. + */ + void rememberIfOrphaned(std::filesystem::path const& directory); + private: boost::asio::strand strand_; std::filesystem::path tempRootDir_; std::chrono::hours deadInstanceRetentionHours_; std::map> instances_; + /** @brief What listOrphaned() found, nothing until it scans. */ + mutable std::optional> orphaned_{}; }; } diff --git a/backend/include/backend/file_tracking/tracker.hpp b/backend/include/backend/file_tracking/tracker.hpp index 396414ae..3f97e149 100644 --- a/backend/include/backend/file_tracking/tracker.hpp +++ b/backend/include/backend/file_tracking/tracker.hpp @@ -202,8 +202,8 @@ namespace FileTracking void removeAllStopped(); /** - * @brief Shows the orphaned copies again, see TrackerView::showOrphanedCopies(). Those of stopped watches are - * left out. + * @brief Shows the orphaned copies again, scanned anew, see TrackerView::showOrphanedCopies(). Those of stopped + * watches are left out. */ void listOrphanedCopies(); diff --git a/backend/include/backend/file_tracking/watch_progress_meter.hpp b/backend/include/backend/file_tracking/watch_progress_meter.hpp index 990bfcd4..22543260 100644 --- a/backend/include/backend/file_tracking/watch_progress_meter.hpp +++ b/backend/include/backend/file_tracking/watch_progress_meter.hpp @@ -56,20 +56,30 @@ namespace FileTracking /** * @brief The upload of @p path was handed to the queue, @p sizeBytes is its size when known. An upload of * @p path still on its way counts as ended without completing. + * @return Its generation, which tells it apart from the earlier uploads of @p path. */ - void uploadQueued(Paths::LocalPath const& path, std::optional sizeBytes = std::nullopt); + std::uint64_t uploadQueued(Paths::LocalPath const& path, std::optional sizeBytes = std::nullopt); /** * @brief The upload of @p path is as far as @p progress, it started on the server. Nothing for an upload - * that is not on its way. + * that is not on its way, or not of @p generation when that is given. */ - void uploadProgressed(Paths::LocalPath const& path, UploadProgress const& progress); + void uploadProgressed( + Paths::LocalPath const& path, + UploadProgress const& progress, + std::optional generation = std::nullopt + ); /** * @brief The upload of @p path ended. End it together with changesEnded() and setChangesPending() for its - * changes, the files it finished count as pending again in between. + * changes, the files it finished count as pending again in between. Nothing for an upload that is not + * on its way, or not of @p generation when that is given. */ - void uploadEnded(Paths::LocalPath const& path, UploadResult result); + void uploadEnded( + Paths::LocalPath const& path, + UploadResult result, + std::optional generation = std::nullopt + ); /** * @brief Forgets what is on its way, for a watch that stops: the uploads count as ended without completing, @@ -108,10 +118,17 @@ namespace FileTracking std::optional sizeBytes{}; /** @brief The last progress, nothing before the upload started on the server. */ std::optional progress{}; + std::uint64_t generation{0u}; }; void endUpload(ActiveUpload const& upload, UploadResult result); + /** + * @brief The upload of @p path on its way, of @p generation when that is given, the end of uploads_ when there is none. + */ + std::map::iterator + activeUpload(Paths::LocalPath const& path, std::optional generation); + private: Ids::WatchId watchId_; std::uint64_t changesPending_{0u}; @@ -121,6 +138,7 @@ namespace FileTracking /** @brief The bytes the ended uploads of the current burst count as, both done and total. */ std::uint64_t burstBytes_{0u}; std::map uploads_{}; + std::uint64_t nextGeneration_{1u}; std::optional lastSync_{}; bool dirty_{false}; }; diff --git a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp index 7067e2ce..da49091b 100644 --- a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp @@ -509,7 +509,12 @@ namespace FileTracking void TempDirInstanceManager::destroyInstance(std::string const& instanceId) { - instances_.erase(instanceId); + const auto instance = instances_.find(instanceId); + if (instance == instances_.end()) + return; + const auto directory = instance->second->instanceDir(); + instances_.erase(instance); + rememberIfOrphaned(directory); } std::expected @@ -554,10 +559,23 @@ namespace FileTracking int TempDirInstanceManager::removeOrphaned() { + orphaned_.reset(); return forceCleanOrphanedInstanceDirs(tempRootDir_, listInstanceIds()); } std::vector TempDirInstanceManager::listOrphaned() const + { + if (!orphaned_) + orphaned_ = scanOrphaned(); + return *orphaned_; + } + + void TempDirInstanceManager::rescanOrphaned() + { + orphaned_.reset(); + } + + std::vector TempDirInstanceManager::scanOrphaned() const { std::vector copies; for (auto const& directory : orphanedInstanceDirs(tempRootDir_, listInstanceIds())) @@ -576,6 +594,32 @@ namespace FileTracking return copies; } + void TempDirInstanceManager::rememberIfOrphaned(std::filesystem::path const& directory) + { + if (!orphaned_ || !isOrphanedDir(directory, listInstanceIds())) + return; + auto copy = orphanedCopyOf(directory); + if (!copy) + return; + std::erase_if( + *orphaned_, + [©](SharedData::OrphanedCopy const& listed) + { + return listed.instanceId == copy->instanceId; + } + ); + const auto place = std::ranges::upper_bound( + *orphaned_, + copy->instanceId.value(), + {}, + [](SharedData::OrphanedCopy const& listed) + { + return listed.instanceId.value(); + } + ); + orphaned_->insert(place, *std::move(copy)); + } + std::expected TempDirInstanceManager::removeOrphanedInstance(Ids::InstanceId const& instanceId) { return plainChildName(instanceId.value()) @@ -594,13 +638,25 @@ namespace FileTracking } ) .and_then( - [&instanceId](std::filesystem::path const& directory) -> std::expected + [this, &instanceId](std::filesystem::path const& directory) -> std::expected { Log::info("Removing the orphaned instance '{}'", instanceId.value()); std::error_code removeError; Utility::removeAllNoFollow(directory, removeError); if (removeError) + { + // Partly removed, the next listing looks again. + orphaned_.reset(); return std::unexpected{Utility::errorMessage(removeError)}; + } + if (orphaned_) + std::erase_if( + *orphaned_, + [&instanceId](SharedData::OrphanedCopy const& listed) + { + return listed.instanceId == instanceId; + } + ); return {}; } ) @@ -641,6 +697,7 @@ namespace FileTracking void TempDirInstanceManager::manualCleanup() { + orphaned_.reset(); scanAndCleanDeadInstanceDirs( tempRootDir_, listInstanceIds(), diff --git a/backend/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 03aaf834..f549dd59 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -91,6 +91,8 @@ namespace FileTracking /** @brief Canonical, like the paths of the changes. */ std::filesystem::path instanceDirectory; InstanceWatch watch; + /** @brief The size of the server's file when it was downloaded. */ + std::uint64_t sizeBytes{0}; /** @brief Runs while the copy was reported deleted or renamed away, see awaitCopyReturn(). */ std::unique_ptr returnWait{}; }; @@ -122,6 +124,8 @@ namespace FileTracking bool shownUploading{false}; /** @brief How far its changes are, by the local paths its uploads started with. */ WatchProgressMeter progress; + /** @brief The progress holds the current count of pending changes, see progressChanged(). */ + bool changesPendingCounted{true}; /** @brief When the view last heard of the progress, it hears of it once per progress interval at most. */ std::chrono::steady_clock::time_point progressShownAt{}; @@ -342,7 +346,10 @@ namespace FileTracking return path.filename() ? std::string{path.filename()->view()} : path.toUtf8(); } - SharedData::TrackedEntry shownEntryOf(Entry const& entry) + /** + * @param watch The local watch of an uploaded original, it knows whether the item is a directory. + */ + SharedData::TrackedEntry shownEntryOf(Entry const& entry, LocalItemWatch const* watch) { return SharedData::TrackedEntry{ .watchId = entry.watchId, @@ -352,9 +359,10 @@ namespace FileTracking .uploading = entry.shownUploading, .downloadedCopy = entry.downloadedCopy() != nullptr, .localPath = entry.localPath, - .isDirectory = entry.downloadedCopy() == nullptr && isDirectoryLocally(entry.localPath), + .isDirectory = entry.downloadedCopy() == nullptr && watch != nullptr && watch->isDirectory(), .stop = std::nullopt, .instanceId = entry.downloadedCopy() ? std::optional{entry.downloadedCopy()->instanceId} : std::nullopt, + .sizeBytes = entry.downloadedCopy() ? entry.downloadedCopy()->sizeBytes : 0u, }; } @@ -645,7 +653,7 @@ namespace FileTracking void show(Entry const& entry) { - view->showEntry(shownEntryOf(entry)); + view->showEntry(shownEntryOf(entry, localWatches->find(entry.watchId))); } void showProgress(Entry& entry, std::chrono::steady_clock::time_point now) @@ -663,6 +671,8 @@ namespace FileTracking std::optional showChangedProgress(Entry& entry, std::chrono::steady_clock::time_point now) { + if (!entry.changesPendingCounted) + countChangesPending(entry); if (entry.progress.isIdle()) entry.progress.finishBurst(secondsSinceEpochNow()); if (!entry.progress.isDirty()) @@ -680,15 +690,25 @@ namespace FileTracking */ void progressChanged(Entry& entry) { - entry.progress.setChangesPending(entry.pendingUploads.changeCount() + entry.remoteChangesOnTheirWay); + // Counting walks every pending upload: during a burst it waits for the next show. + if (entry.pendingUploads.empty() || entry.progress.isIdle()) + countChangesPending(entry); + else + entry.changesPendingCounted = false; entry.progress.setWaitingForAnswer(conflicts.countOf(entry.watchId)); - if (!entry.progress.isDirty()) + if (entry.changesPendingCounted && !entry.progress.isDirty()) return; if (entry.progress.isIdle()) return postIdleProgress(); scheduleProgress(std::chrono::steady_clock::now() + progressInterval); } + static void countChangesPending(Entry& entry) + { + entry.progress.setChangesPending(entry.pendingUploads.changeCount() + entry.remoteChangesOnTheirWay); + entry.changesPendingCounted = true; + } + void progressChanged(Ids::WatchId const& watchId) { if (auto* entry = find(watchId)) @@ -769,13 +789,19 @@ namespace FileTracking /** * @brief The upload of @p path in the watch @p watchId, the path it started with, is as far as @p progress. + * @param generation Its generation in the progress, see WatchProgressMeter::uploadQueued(). */ - void onUploadProgress(Ids::WatchId const& watchId, Paths::LocalPath const& path, UploadProgress const& progress) + void onUploadProgress( + Ids::WatchId const& watchId, + Paths::LocalPath const& path, + std::uint64_t generation, + UploadProgress const& progress + ) { auto* entry = find(watchId); if (!entry) return; - entry->progress.uploadProgressed(path, progress); + entry->progress.uploadProgressed(path, progress, generation); progressChanged(*entry); } @@ -912,6 +938,7 @@ namespace FileTracking copy = OpenedCopy{.instanceId = instanceId, .localPath = destination}, instanceDirectory, remotePath, + sizeBytes, watch, claim, onOpened](SharedData::OperationCompletionReason reason) @@ -920,7 +947,9 @@ namespace FileTracking if (claim) claim->release(); if (const auto self = weak.lock()) - self->onDownloaded(copy, instanceDirectory, remotePath, watch, reason, onOpened); + self->onDownloaded( + copy, instanceDirectory, remotePath, sizeBytes, watch, reason, onOpened + ); } ); } @@ -938,6 +967,7 @@ namespace FileTracking OpenedCopy const& copy, std::filesystem::path const& instanceDirectory, Paths::RemotePath const& remotePath, + std::uint64_t sizeBytes, bool watch, SharedData::OperationCompletionReason reason, OnOpened const& onOpened @@ -983,6 +1013,7 @@ namespace FileTracking .instanceId = copy.instanceId, .instanceDirectory = instanceDirectory, .watch = *std::move(instanceWatch), + .sizeBytes = sizeBytes, }, .remotePath = remotePath, .localPath = copy.localPath, @@ -1111,11 +1142,9 @@ namespace FileTracking entry.progress.dropPending(); entry.progress.finishBurst(endedAt); view->showWatchProgress(entry.progress.snapshot()); - auto shown = shownEntryOf(entry); - shown.uploading = false; // The item may be gone or elsewhere by now, its watch knows what it was. - if (auto const* const watch = localWatches->find(watchId)) - shown.isDirectory = watch->isDirectory(); + auto shown = shownEntryOf(entry, localWatches->find(watchId)); + shown.uploading = false; return shown; } @@ -2449,6 +2478,8 @@ namespace FileTracking void listOrphanedCopies() { + // Other processes may have left orphans since the last scan. + instances->rescanOrphaned(); showOrphanedCopies(); } @@ -2543,7 +2574,7 @@ namespace FileTracking if (upload.change.isDirectory) entry->pendingUploads.carries(upload.change.path, entryCountOf(upload.change.path.native())); entry->pendingUploads.sent(upload.change.path, upload.destination, upload.change.isDirectory); - entry->progress.uploadQueued(upload.change.path, fileSizeOf(upload.change.path)); + const auto generation = entry->progress.uploadQueued(upload.change.path, fileSizeOf(upload.change.path)); progressChanged(*entry); const auto queued = remote->upload( UploadRequest{ @@ -2555,17 +2586,18 @@ namespace FileTracking .expectedDestination = expected, .continuesPartialOf = upload.continuesPartialOf, }, - [weak = weak_from_this(), upload, expected](UploadEnd const& ended) + [weak = weak_from_this(), upload, expected, generation](UploadEnd const& ended) { if (const auto self = weak.lock()) - self->onUploadEnded(upload, expected, ended); + self->onUploadEnded(upload, expected, generation, ended); }, [weak = weak_from_this(), watchId = upload.watchId, - path = upload.change.path](UploadProgress const& progress) + path = upload.change.path, + generation](UploadProgress const& progress) { if (const auto self = weak.lock()) - self->onUploadProgress(watchId, path, progress); + self->onUploadProgress(watchId, path, generation, progress); } ); if (queued) @@ -2574,7 +2606,9 @@ namespace FileTracking Log::warn("FileTracking: did not upload '{}': {}", upload.change.path.toUtf8(), queued.error()); if (auto* stillWatched = find(upload.watchId)) { - stillWatched->progress.uploadEnded(upload.change.path, WatchProgressMeter::UploadResult::NotCompleted); + stillWatched->progress.uploadEnded( + upload.change.path, WatchProgressMeter::UploadResult::NotCompleted, generation + ); recordFailure( *stillWatched, upload.change.path, @@ -2589,13 +2623,18 @@ namespace FileTracking * @brief Once an upload ended, what the server has now is the state the next check compares with. The upload * stays on its way until the remote told that state. * @param expected What the upload expected the server to have at its destination. + * @param generation Its generation in the progress, see WatchProgressMeter::uploadQueued(). */ - void - onUploadEnded(Upload const& upload, std::optional const& expected, UploadEnd const& ended) + void onUploadEnded( + Upload const& upload, + std::optional const& expected, + std::uint64_t generation, + UploadEnd const& ended + ) { auto* entry = find(upload.watchId); if (entry) - entry->progress.uploadEnded(upload.change.path, uploadResultOf(ended)); + entry->progress.uploadEnded(upload.change.path, uploadResultOf(ended), generation); // The server's copy changed meanwhile and stayed, the upload asks about it now unless the user stopped // checking since. if (entry && ended.changedOnServer) diff --git a/backend/source/backend/file_tracking/watch_progress_meter.cpp b/backend/source/backend/file_tracking/watch_progress_meter.cpp index d03b2ee8..f8e24f3c 100644 --- a/backend/source/backend/file_tracking/watch_progress_meter.cpp +++ b/backend/source/backend/file_tracking/watch_progress_meter.cpp @@ -75,32 +75,55 @@ namespace FileTracking dirty_ = true; } - void WatchProgressMeter::uploadQueued(Paths::LocalPath const& path, std::optional sizeBytes) + std::uint64_t + WatchProgressMeter::uploadQueued(Paths::LocalPath const& path, std::optional sizeBytes) { if (const auto previous = uploads_.find(path); previous != uploads_.end()) endUpload(previous->second, UploadResult::NotCompleted); - uploads_.insert_or_assign(path, ActiveUpload{.sizeBytes = sizeBytes, .progress = std::nullopt}); + const auto generation = nextGeneration_++; + uploads_.insert_or_assign( + path, ActiveUpload{.sizeBytes = sizeBytes, .progress = std::nullopt, .generation = generation} + ); dirty_ = true; + return generation; } - void WatchProgressMeter::uploadProgressed(Paths::LocalPath const& path, UploadProgress const& progress) + void WatchProgressMeter::uploadProgressed( + Paths::LocalPath const& path, + UploadProgress const& progress, + std::optional generation + ) { - const auto upload = uploads_.find(path); + const auto upload = activeUpload(path, generation); if (upload == uploads_.end()) return; upload->second.progress = progress; dirty_ = true; } - void WatchProgressMeter::uploadEnded(Paths::LocalPath const& path, UploadResult result) + void WatchProgressMeter::uploadEnded( + Paths::LocalPath const& path, + UploadResult result, + std::optional generation + ) { - const auto node = uploads_.extract(path); - if (node.empty()) + const auto upload = activeUpload(path, generation); + if (upload == uploads_.end()) return; - endUpload(node.mapped(), result); + endUpload(upload->second, result); + uploads_.erase(upload); dirty_ = true; } + std::map::iterator + WatchProgressMeter::activeUpload(Paths::LocalPath const& path, std::optional generation) + { + const auto upload = uploads_.find(path); + if (upload == uploads_.end() || (generation && upload->second.generation != *generation)) + return uploads_.end(); + return upload; + } + void WatchProgressMeter::endUpload(ActiveUpload const& upload, UploadResult result) { burstBytes_ += result == UploadResult::Completed ? bytesTotalOf(upload.progress, upload.sizeBytes) diff --git a/backend/test/backend/test_watch_progress_meter.hpp b/backend/test/backend/test_watch_progress_meter.hpp index f7de94c9..b54e51a8 100644 --- a/backend/test/backend/test_watch_progress_meter.hpp +++ b/backend/test/backend/test_watch_progress_meter.hpp @@ -457,6 +457,24 @@ namespace Test::WatchProgressMeterCases EXPECT_EQ(progress.changesWaiting, 1u); } + TEST_F(WatchProgressMeterTest, TheLateEndOfAnUploadQueuedAgainLeavesTheNewOneOnItsWay) + { + meter_.setChangesPending(1u); + const auto first = meter_.uploadQueued(localPathOf("/watched/a.txt"), 100u); + const auto second = meter_.uploadQueued(localPathOf("/watched/a.txt"), 200u); + + meter_.uploadEnded(localPathOf("/watched/a.txt"), UploadResult::NotCompleted, first); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 50u, 200u), first); + meter_.uploadProgressed(localPathOf("/watched/a.txt"), fileProgress("/watched/a.txt", 80u, 200u), second); + + const auto progress = meter_.snapshot(); + EXPECT_FALSE(meter_.isIdle()); + EXPECT_EQ(progress.bytesDone, 80u); + EXPECT_EQ(progress.bytesTotal, 200u); + ASSERT_EQ(progress.inFlight.size(), 1u); + EXPECT_EQ(progress.inFlight.front().bytes, 80u); + } + TEST_F(WatchProgressMeterTest, DroppingWhatIsOnItsWayLetsTheBurstFinish) { meter_.changesEnded(1u); diff --git a/frontend/source/frontend/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index ded49ad2..b527e286 100644 --- a/frontend/source/frontend/session_components/file_tracking.cpp +++ b/frontend/source/frontend/session_components/file_tracking.cpp @@ -125,7 +125,7 @@ struct FileTrackingPanel::Implementation return; client.open( entry->remotePath, - 0u, + entry->sizeBytes, true, lifetime.guarded( [this, watchId](Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) diff --git a/shared_data/include/shared_data/file_tracking/tracked_entry.hpp b/shared_data/include/shared_data/file_tracking/tracked_entry.hpp index b4c9c830..47c1a636 100644 --- a/shared_data/include/shared_data/file_tracking/tracked_entry.hpp +++ b/shared_data/include/shared_data/file_tracking/tracked_entry.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -37,10 +38,12 @@ namespace SharedData std::optional stop{}; /** @brief The instance directory of a downloaded copy. */ std::optional instanceId{}; + /** @brief The size of the server's file a downloaded copy was downloaded from, as it was then. */ + std::uint64_t sizeBytes{0}; }; BOOST_DESCRIBE_STRUCT( TrackedEntry, (), - (watchId, name, remotePath, options, uploading, downloadedCopy, localPath, isDirectory, stop, instanceId) + (watchId, name, remotePath, options, uploading, downloadedCopy, localPath, isDirectory, stop, instanceId, sizeBytes) ) } From bcb427b37c25d54b3d807224a91fc34d92ec0dc9 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 25 Oct 2026 02:27:59 +0100 Subject: [PATCH 114/114] Made the uncheckable change test wait for the report before src closes - FSEvents may not report a change below a directory no one can look into, so on macOS the failure never came - The second save of main.cpp now waits for the upload of the first and finds src closed when its turn comes --- backend/test/backend/test_tracker.hpp | 33 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index 88cc2e41..9614f02c 100644 --- a/backend/test/backend/test_tracker.hpp +++ b/backend/test/backend/test_tracker.hpp @@ -3728,15 +3728,40 @@ namespace Test if (::geteuid() == 0) GTEST_SKIP() << "root can look into any directory"; const auto source = root_ / "local" / "project" / "src"; + const auto file = source / "main.cpp"; ASSERT_TRUE(tracker_->uploadAndWatch(local(root_ / "local" / "project"), remote("/srv/project")).has_value()); + const auto watchId = onlyEntry().watchId; completeEverything(); + write(file, "first change"); + ASSERT_TRUE(runUntil( + [this]() + { + return uploadsTo("/srv/project/src/main.cpp") == 1u; + } + )); + const auto sent = sentFilesOf(file); + const auto sentState = stateOf(file); - write(source / "new.cpp", "new"); + // Reported while src is open, FSEvents may not tell of a change below a directory no one can look into. It + // waits for the upload of the first change, src is closed by then. + write(file, "second change"); + ASSERT_TRUE(runUntilProgress( + watchId, + [](SharedData::WatchProgress const& progress) + { + return progress.changesPending == 2u; + } + )); std::filesystem::permissions(source, std::filesystem::perms::none); auto restore = Nui::ScopeExit{[&source]() noexcept { std::error_code errorCode{}; std::filesystem::permissions(source, std::filesystem::perms::owner_all, errorCode); }}; + remote_->server["/srv/project/src/main.cpp"] = sentState; + endAs( + *lastUploadTo("/srv/project/src/main.cpp"), + {.reason = SharedData::OperationCompletionReason::Completed, .destinationState = sentState, .sentFiles = sent} + ); ASSERT_TRUE(runUntil( [this]() { @@ -3745,12 +3770,12 @@ namespace Test )); runFor(std::chrono::milliseconds{500}); - EXPECT_EQ(uploadsTo("/srv/project/src/new.cpp"), 0u); + EXPECT_EQ(uploadsTo("/srv/project/src/main.cpp"), 1u); EXPECT_TRUE(remote_->of("remove").empty()); ASSERT_EQ(view_->failures.size(), 1u); const auto failure = view_->failures.begin()->second; - EXPECT_EQ(failure.localPath.native(), source / "new.cpp"); - EXPECT_EQ(failure.remotePath.value(), "/srv/project/src/new.cpp"); + EXPECT_EQ(failure.localPath.native(), file); + EXPECT_EQ(failure.remotePath.value(), "/srv/project/src/main.cpp"); EXPECT_EQ(failure.reason, SharedData::UploadFailureReason::PermissionDeniedLocally); EXPECT_TRUE(failure.detail.has_value()); }