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/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/bulk_entry_progress.hpp b/backend/include/backend/file_tracking/bulk_entry_progress.hpp new file mode 100644 index 00000000..6c74bb45 --- /dev/null +++ b/backend/include/backend/file_tracking/bulk_entry_progress.hpp @@ -0,0 +1,103 @@ +#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}; + /** @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{}; + }; + + 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 The entries seen on their way that were not told done yet, by index. */ + std::vector started_{}; + std::uint64_t updates_{0u}; + }; + + /** + * @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/include/backend/file_tracking/conflict_list.hpp b/backend/include/backend/file_tracking/conflict_list.hpp new file mode 100644 index 00000000..f39d7384 --- /dev/null +++ b/backend/include/backend/file_tracking/conflict_list.hpp @@ -0,0 +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{0u}; + }; + + /** + * @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_{0u}; + }; +} 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..8b34da79 --- /dev/null +++ b/backend/include/backend/file_tracking/failure_list.hpp @@ -0,0 +1,271 @@ +#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. A retried failure leaves the list until its path + * uploads or fails again, see retry(). 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; + } + 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}); + 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 @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; + 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. Retried ones there + * are forgotten alike. + * @return They, in the order their paths first failed. + */ + std::vector removeWithin( + Ids::WatchId const& watchId, + Paths::LocalPath const& directory, + 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) + { + 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) + { + retried_.erase(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(); + retried_.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{0u}; + }; + + /** + * @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; + } + + 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); + 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_{}; + /** @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 new file mode 100644 index 00000000..dcc66c5b --- /dev/null +++ b/backend/include/backend/file_tracking/failure_reason.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#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 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. + */ + 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/include/backend/file_tracking/frontend_tracker_view.hpp b/backend/include/backend/file_tracking/frontend_tracker_view.hpp index 929a1ab9..caba360b 100644 --- a/backend/include/backend/file_tracking/frontend_tracker_view.hpp +++ b/backend/include/backend/file_tracking/frontend_tracker_view.hpp @@ -16,8 +16,10 @@ 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). + * onEntriesEnded (SharedData::EndedWatches), onConflict (SharedData::WatchConflict), + * onConflictsRemoved (SharedData::RemovedConflicts), onFailure (SharedData::UploadFailure), + * onFailuresRemoved (SharedData::RemovedFailures), onWatchProgress (SharedData::WatchProgress) and + * onOrphanedCopies (SharedData::OrphanedCopies). */ class FrontendTrackerView : public TrackerView { @@ -27,9 +29,12 @@ 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 removeConflicts(SharedData::RemovedConflicts const& removed) override; + void showFailure(SharedData::UploadFailure const& failure) override; + void removeFailures(SharedData::RemovedFailures const& removed) 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/temp_dir_instance_manager.hpp b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp index 29371fd5..24b5bb77 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 @@ -11,6 +13,7 @@ #include #include #include +#include #include #include @@ -65,7 +68,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 @@ -151,6 +155,26 @@ 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, 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. 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 + * directory is no orphan (see listOrphaned()) or it cannot be removed. + */ + std::expected removeOrphanedInstance(Ids::InstanceId const& instanceId); + /** * @brief Look up an instance by ID. * @@ -179,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/temp_dir_instancing.hpp b/backend/include/backend/file_tracking/temp_dir_instancing.hpp index bb38d696..1227d1dd 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 or cleanupNow() ran. + */ + 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/include/backend/file_tracking/tracker.hpp b/backend/include/backend/file_tracking/tracker.hpp index 095faa3c..3f97e149 100644 --- a/backend/include/backend/file_tracking/tracker.hpp +++ b/backend/include/backend/file_tracking/tracker.hpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include #include #include @@ -144,7 +144,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); @@ -159,20 +160,76 @@ namespace FileTracking discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& path); /** - * @brief The user's answer to the question @p questionId, see TrackerView::ask(). + * @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 answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer); + 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, scanned anew, see TrackerView::showOrphanedCopies(). Those of stopped + * watches are left out. + */ + void listOrphanedCopies(); + + /** + * @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 listOrphanedCopies() shows, 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, - * 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/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/include/backend/file_tracking/tracker_view.hpp b/backend/include/backend/file_tracking/tracker_view.hpp index 01c53cc9..4105d969 100644 --- a/backend/include/backend/file_tracking/tracker_view.hpp +++ b/backend/include/backend/file_tracking/tracker_view.hpp @@ -2,17 +2,18 @@ #include #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 +26,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; @@ -38,18 +39,34 @@ 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. + * @brief Shows @p conflict, a local change that waits for the user's answer. + */ + virtual void showConflict(SharedData::WatchConflict const& conflict) = 0; + + /** + * @brief The conflicts of @p removed were answered or dropped with their watch, all in one message. + */ + virtual void removeConflicts(SharedData::RemovedConflicts const& removed) = 0; + + /** + * @brief Shows @p failure, new or updated. + */ + virtual void showFailure(SharedData::UploadFailure const& failure) = 0; + + /** + * @brief The failures of @p removed were retried, dismissed, went through or were dropped with their watch, all + * in one message. */ - virtual void ask(SharedData::ServerChangedQuestion const& question) = 0; + virtual void removeFailures(SharedData::RemovedFailures const& removed) = 0; /** - * @brief Shows how many instance directories no live process holds any more. + * @brief Shows how far the watch of @p progress is, at most five times a second per watch. */ - virtual void showOrphanedCount(std::size_t count) = 0; + virtual void showWatchProgress(SharedData::WatchProgress const& progress) = 0; /** - * @brief Shows how far the tracker is with the changes on their way, at most a few times a second. + * @brief Shows the orphaned copies, all of them, replacing what was shown before. */ - virtual void showProgress(SharedData::TrackerProgress const& progress) = 0; + virtual void showOrphanedCopies(SharedData::OrphanedCopies const& copies) = 0; }; } 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/file_tracking/watch_progress_meter.hpp b/backend/include/backend/file_tracking/watch_progress_meter.hpp new file mode 100644 index 00000000..22543260 --- /dev/null +++ b/backend/include/backend/file_tracking/watch_progress_meter.hpp @@ -0,0 +1,145 @@ +#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. + * @return Its generation, which tells it apart from the earlier uploads of @p path. + */ + 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, or not of @p generation when that is given. + */ + 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. 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, + std::optional generation = std::nullopt + ); + + /** + * @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{}; + 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}; + 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::uint64_t nextGeneration_{1u}; + std::optional lastSync_{}; + bool dirty_{false}; + }; +} 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/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/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_file_problem.hpp b/backend/include/backend/sftp/local_file_problem.hpp new file mode 100644 index 00000000..0c2763ef --- /dev/null +++ b/backend/include/backend/sftp/local_file_problem.hpp @@ -0,0 +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/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 45d9b6c3..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 @@ -15,7 +16,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -35,6 +38,7 @@ #include #include #include +#include #include class OperationQueue @@ -47,6 +51,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( @@ -141,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, @@ -155,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 ); /** @@ -254,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, @@ -261,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 ); /** @@ -373,6 +386,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. @@ -419,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. */ @@ -508,8 +532,12 @@ 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_{}; + /** @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 3e1af413..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; @@ -254,6 +260,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/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/batching_remote.cpp b/backend/source/backend/file_tracking/batching_remote.cpp index 348809a4..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 @@ -19,6 +20,7 @@ namespace FileTracking { UploadRequest request; TrackerRemote::OnUploadEnded onEnded; + TrackerRemote::OnUploadProgress onProgress; }; struct GatheredRemoval @@ -58,19 +60,33 @@ 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); }; } - 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 @@ -139,6 +155,7 @@ namespace FileTracking { std::vector bulk; std::vector bulkEnds; + std::vector bulkProgress; std::vector single; for (auto& upload : gathered) { @@ -146,29 +163,38 @@ 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); + for (std::size_t index = 0; index < bulk.size(); ++index) + if (bulkEnds[index]) + bulkEnds[index](refusedUpload(bulk[index], queued.error())); } } 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()); if (upload.onEnded) - upload.onEnded(failedUpload); + upload.onEnded(refusedUpload(upload.request, queued.error())); } } } @@ -196,7 +222,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 +286,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/bulk_entry_progress.cpp b/backend/source/backend/file_tracking/bulk_entry_progress.cpp new file mode 100644 index 00000000..52940a0a --- /dev/null +++ b/backend/source/backend/file_tracking/bulk_entry_progress.cpp @@ -0,0 +1,202 @@ +#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 auto update = ++updates_; + std::vector changed; + 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]; + 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; + } + + 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/source/backend/file_tracking/failure_reason.cpp b/backend/source/backend/file_tracking/failure_reason.cpp new file mode 100644 index 00000000..a50d709e --- /dev/null +++ b/backend/source/backend/file_tracking/failure_reason.cpp @@ -0,0 +1,259 @@ +#include +#include +#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")}, + }; + } + + 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) + { + 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/source/backend/file_tracking/frontend_tracker_view.cpp b/backend/source/backend/file_tracking/frontend_tracker_view.cpp index a59a7445..68b7b934 100644 --- a/backend/source/backend/file_tracking/frontend_tracker_view.cpp +++ b/backend/source/backend/file_tracking/frontend_tracker_view.cpp @@ -28,19 +28,34 @@ namespace FileTracking RpcHelper::callRemoteSafely(*hub_, eventName("onEntriesEnded"), ended); } - void FrontendTrackerView::ask(SharedData::ServerChangedQuestion const& question) + void FrontendTrackerView::showConflict(SharedData::WatchConflict const& conflict) { - RpcHelper::callRemoteSafely(*hub_, eventName("onQuestion"), question); + RpcHelper::callRemoteSafely(*hub_, eventName("onConflict"), conflict); } - void FrontendTrackerView::showOrphanedCount(std::size_t count) + void FrontendTrackerView::removeConflicts(SharedData::RemovedConflicts const& removed) { - RpcHelper::callRemoteSafely(*hub_, eventName("onOrphanedCount"), count); + RpcHelper::callRemoteSafely(*hub_, eventName("onConflictsRemoved"), removed); } - void FrontendTrackerView::showProgress(SharedData::TrackerProgress const& progress) + void FrontendTrackerView::showFailure(SharedData::UploadFailure const& failure) { - RpcHelper::callRemoteSafely(*hub_, eventName("onProgress"), progress); + RpcHelper::callRemoteSafely(*hub_, eventName("onFailure"), failure); + } + + void FrontendTrackerView::removeFailures(SharedData::RemovedFailures const& removed) + { + RpcHelper::callRemoteSafely(*hub_, eventName("onFailuresRemoved"), removed); + } + + 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 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..da49091b 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,195 @@ namespace FileTracking Log::warn("Failed to remove dead instance '{}': {}", dirName, Utility::errorMessage(rmErr)); } } + + /** + * @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. + */ + 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 @@ -302,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 @@ -347,9 +559,115 @@ 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())) + { + 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; + } + + 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()) + .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( + [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 {}; + } + ) + .transform_error( + [&instanceId](std::string const& reason) + { + return fmt::format("Cannot remove the orphaned instance '{}': {}", instanceId.value(), reason); + } + ); + } + TemporaryDirectoryInstance* TempDirInstanceManager::findInstance(std::string const& instanceId) const { @@ -379,6 +697,7 @@ namespace FileTracking void TempDirInstanceManager::manualCleanup() { + orphaned_.reset(); scanAndCleanDeadInstanceDirs( tempRootDir_, listInstanceIds(), diff --git a/backend/source/backend/file_tracking/temp_dir_instancing.cpp b/backend/source/backend/file_tracking/temp_dir_instancing.cpp index fa07a96b..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 // ------------------------------------------------------------------------- @@ -152,6 +179,28 @@ namespace FileTracking ); } + std::expected TemporaryDirectoryInstance::recordOrigin(Paths::RemotePath const& remotePath) + { + 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() { if (!impl_) @@ -192,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/source/backend/file_tracking/tracker.cpp b/backend/source/backend/file_tracking/tracker.cpp index 0d220796..f549dd59 100644 --- a/backend/source/backend/file_tracking/tracker.cpp +++ b/backend/source/backend/file_tracking/tracker.cpp @@ -1,23 +1,31 @@ #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 @@ -83,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{}; }; @@ -112,6 +122,12 @@ 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 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{}; DownloadedCopy const* downloadedCopy() const { @@ -199,18 +215,115 @@ 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()); + } + + 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. */ 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 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. + */ + 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(), + }; + } + + /** + * @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. @@ -233,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, @@ -242,6 +358,11 @@ namespace FileTracking .options = entry.options, .uploading = entry.shownUploading, .downloadedCopy = entry.downloadedCopy() != nullptr, + .localPath = 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, }; } @@ -328,11 +449,43 @@ 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 { - /** @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}; /** @@ -365,15 +518,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; @@ -385,6 +529,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(). */ @@ -392,15 +538,14 @@ 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 Changes that ended since nothing was on its way any more, see showProgress(). */ - std::uint64_t changesDone{0}; - SharedData::TrackerProgress shownProgress{}; + /** @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 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. */ @@ -508,94 +653,156 @@ namespace FileTracking void show(Entry const& entry) { - view->showEntry(shownEntryOf(entry)); + view->showEntry(shownEntryOf(entry, localWatches->find(entry.watchId))); } - 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 = waitingQuestions.size() + (openQuestion ? 1u : 0u), - }; + 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 !openQuestion && waitingQuestions.empty() && - std::ranges::all_of( - std::views::values(entries), - [](Entry const& entry) - { - return entry.pendingUploads.empty() && entry.remoteChangesOnTheirWay == 0; - } - ); + if (!entry.changesPendingCounted) + countChangesPending(entry); + 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) { - if (nothingOnItsWay() && !idleProgressPosted) + // 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.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)) + progressChanged(*entry); + } + + void postIdleProgress() + { + 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) + { + 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. + * @param generation Its generation in the progress, see WatchProgressMeter::uploadQueued(). + */ + void onUploadProgress( + Ids::WatchId const& watchId, + Paths::LocalPath const& path, + std::uint64_t generation, + UploadProgress const& progress + ) { - changesDone += count; - progressChanged(); + auto* entry = find(watchId); + if (!entry) + return; + entry->progress.uploadProgressed(path, progress, generation); + progressChanged(*entry); } /** @@ -604,15 +811,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); }; } @@ -634,24 +844,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) @@ -666,13 +891,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; } @@ -692,6 +918,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; @@ -709,6 +938,7 @@ namespace FileTracking copy = OpenedCopy{.instanceId = instanceId, .localPath = destination}, instanceDirectory, remotePath, + sizeBytes, watch, claim, onOpened](SharedData::OperationCompletionReason reason) @@ -717,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 + ); } ); } @@ -735,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 @@ -780,10 +1013,12 @@ namespace FileTracking .instanceId = copy.instanceId, .instanceDirectory = instanceDirectory, .watch = *std::move(instanceWatch), + .sizeBytes = sizeBytes, }, .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); @@ -854,6 +1089,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{ @@ -870,11 +1106,46 @@ namespace FileTracking void stop(Ids::WatchId const& watchId) { - const auto instanceId = forget(watchId); - if (!instanceId) - return; - releaseInstance(*instanceId); - view->showOrphanedCount(instances->countOrphaned()); + if (retire(watchId, watchStopOf(SharedData::WatchStopReason::StoppedByUser))) + showOrphanedCopies(); + } + + /** + * @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()); + // The item may be gone or elsewhere by now, its watch knows what it was. + auto shown = shownEntryOf(entry, localWatches->find(watchId)); + shown.uploading = false; + return shown; } /** @@ -893,32 +1164,47 @@ 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()); - std::vector dropped; - std::erase_if( - waitingQuestions, - [&watchId, &dropped](Upload const& upload) - { - if (upload.watchId != watchId) - return false; - dropped.push_back(upload); - return true; - } - ); + return; + Log::info("FileTracking: forgetting watch {}", watchId.value()); + dropProblems(watchId); view->removeEntry(watchId); - progressChanged(); - for (auto const& upload : dropped) - leaveOrder(upload); - const auto* copy = node.mapped().downloadedCopy(); - return copy ? std::optional{copy->instanceId} : std::nullopt; + } + + /** + * @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) + { + const auto dropped = conflicts.removeWatch(watchId); + hideConflicts(removedConflictsOf(dropped)); + hideFailures(removedFailuresOf(failures.removeWatch(watchId))); + for (auto const& listed : dropped) + leaveOrder(listed.context); + } + + /** + * @brief Takes the conflicts of @p removed off the view in one message, none for an empty list. + */ + void hideConflicts(SharedData::RemovedConflicts const& removed) + { + if (!removed.conflictIds.empty()) + view->removeConflicts(removed); + } + + /** + * @brief Takes the failures of @p removed off the view in one message, none for an empty list. + */ + void hideFailures(SharedData::RemovedFailures const& removed) + { + if (!removed.failureIds.empty()) + view->removeFailures(removed); } void setOption(Ids::WatchId const& watchId, SharedData::WatchOption option, bool value) @@ -959,6 +1245,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, {})); @@ -969,38 +1265,33 @@ 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(); + failures.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(); 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() { + 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 = {}}; - for (auto const& entry : std::views::values(entries)) - ended.entries.push_back(shownEntryOf(entry)); - 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& shown : ended.entries) - view->removeEntry(shown.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); - if (progressShown) - view->showProgress(shownProgress); - view->showOrphanedCount(instances->countOrphaned()); + showOrphanedCopies(); } /** @@ -1036,6 +1327,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); @@ -1154,7 +1472,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; @@ -1358,11 +1676,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)}}); + showOrphanedCopies(); } /** @@ -1382,16 +1700,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(), @@ -1415,6 +1725,37 @@ 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) + return; + view->showEnded({.reason = SharedData::WatchEndReason::Overlap, .entries = {*std::move(retired)}}); + showOrphanedCopies(); + } + void onMoved(Entry& entry, Paths::LocalPath const& oldPath, LocalChange const& change) { const auto oldRemotePath = remotePathOf(entry, oldPath); @@ -1704,7 +2045,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); @@ -1798,16 +2139,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); @@ -1817,11 +2158,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); } /** @@ -1845,84 +2193,365 @@ 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) ); + if (const auto* entry = find(upload.watchId)) + recordFailure(*entry, upload.change.path, upload.destination, failureInfoOf(error)); finishUpload(upload, UploadOutcome::NotUploaded); } - void askServerChanged(Upload upload) + /** + * @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) { - waitingQuestions.push_back(std::move(upload)); - askNextServerChanged(); + const auto watchId = upload.watchId; + if (conflictStands(upload)) + { + 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) + { + Log::warn( + "FileTracking: the conflict about '{}' replaced another one", added.conflict.localPath.toUtf8() + ); + leaveOrder(*added.replaced); + } + Log::info( + "FileTracking: '{}' changed on the server, waiting for an answer about it", + added.conflict.remotePath.value() + ); + view->showConflict(added.conflict); + } + progressChanged(watchId); } - void askNextServerChanged() + /** + * @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) { - while (!openQuestion && !waitingQuestions.empty()) + const auto* entry = find(upload.watchId); + if (!entry || entry->pendingUploads.movedAway(upload.change.path) || + !upload.change.path.isWithin(entry->localPath)) { - 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)) - { - enqueueUpload(std::move(upload)); - continue; - } - if (isGoneLocally(upload.change.path)) - { - onGoneLocally(*entry, upload); - continue; - } - openQuestion = OpenQuestion{.questionId = Ids::generateQuestionId(), .upload = std::move(upload)}; - view->ask({.questionId = openQuestion->questionId, .remotePath = openQuestion->upload.destination}); + finishUpload(upload, UploadOutcome::NotUploaded); + return false; } - progressChanged(); + if (isGoneLocally(upload.change.path)) + { + onGoneLocally(*entry, upload); + return false; + } + return true; } - void answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer answer) + void answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) { - if (!openQuestion || openQuestion->questionId != questionId) + auto listed = conflicts.remove(conflictId); + if (!listed) + { + Log::warn("FileTracking: there is no conflict {} to answer", conflictId.value()); return; - auto upload = std::move(openQuestion->upload); - openQuestion.reset(); - progressChanged(); - switch (answer) - { - case SharedData::ServerChangedAnswer::Replace: - enqueueUpload(std::move(upload)); - break; - case SharedData::ServerChangedAnswer::Keep: + } + const auto watchId = listed->conflict.watchId; + hideConflicts({.conflictIds = {conflictId}}); + resolveConflict(std::move(listed->context), resolution); + progressChanged(watchId); + } + + void answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) + { + 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) + ); + hideConflicts(removedConflictsOf(listed)); + for (auto& conflict : listed) + resolveConflict(std::move(conflict.context), resolution); + progressChanged(watchId); + } + + 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()); - declineUpload(upload); - break; - case SharedData::ServerChangedAnswer::Dismissed: - return abandonServerChangedQuestions(upload); + return declineUpload(upload); } - askNextServerChanged(); } /** - * @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. + * @brief Uploads @p upload over whatever the server has, once the path is checked again. */ - void abandonServerChangedQuestions(Upload const& upload) + void replaceOnServer(Upload upload) { - Log::info( - "FileTracking: the question about '{}' was replaced by another dialog.", upload.destination.value() + if (!conflictStands(upload)) + return; + upload.checkServer = false; + checkServerThenUpload(std::move(upload)); + } + + void retryFailure(Ids::FailureId const& failureId) + { + const auto retried = failures.retry(failureId); + if (!retried) + { + Log::warn("FileTracking: there is no failure {} to retry", failureId.value()); + return; + } + hideFailures({.failureIds = {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, + } ); - declineUpload(upload); - for (auto const& waiting : std::exchange(waitingQuestions, {})) - declineUpload(waiting); + } + + void dismissFailure(Ids::FailureId const& failureId) + { + if (!failures.remove(failureId)) + { + Log::warn("FileTracking: there is no failure {} to dismiss", failureId.value()); + return; + } + hideFailures({.failureIds = {failureId}}); + } + + void retryAllFailures(Ids::WatchId const& watchId) + { + 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()); + return; + } + hideFailures(removedFailuresOf(retried)); + for (auto const& failure : retried) + retryUpload(failure); + } + + void dismissAllFailures(Ids::WatchId const& watchId) + { + 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()); + hideFailures(removedFailuresOf(dismissed)); } /** - * @brief The change is not uploaded, and the changes that arrived while it was asked about wait for the next - * save. + * @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)) + hideFailures({.failureIds = {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); + } + hideFailures(removedFailuresOf(failures.removeWithin(entry.watchId, upload.change.path, leftOut))); + } + + void removeStopped(Ids::WatchId const& watchId) + { + 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); + // A stopped copy is an orphan from now on. + showOrphanedCopies(); + } + + void removeAllStopped() + { + 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); + showOrphanedCopies(); + } + + void listOrphanedCopies() + { + // Other processes may have left orphans since the last scan. + instances->rescanOrphaned(); + showOrphanedCopies(); + } + + std::expected removeOrphanedCopy(Ids::InstanceId const& instanceId) + { + 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() + { + 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() + )}; + } + + /** + * @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) { @@ -1945,6 +2574,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); + const auto generation = entry->progress.uploadQueued(upload.change.path, fileSizeOf(upload.change.path)); + progressChanged(*entry); const auto queued = remote->upload( UploadRequest{ .source = upload.change.path, @@ -1952,35 +2583,66 @@ 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, generation](UploadEnd const& ended) { if (const auto self = weak.lock()) - self->onUploadEnded(upload, ended); + self->onUploadEnded(upload, expected, generation, ended); + }, + [weak = weak_from_this(), + watchId = upload.watchId, + path = upload.change.path, + generation](UploadProgress const& progress) + { + if (const auto self = weak.lock()) + self->onUploadProgress(watchId, path, generation, 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 (auto* stillWatched = find(upload.watchId)) + { + stillWatched->progress.uploadEnded( + upload.change.path, WatchProgressMeter::UploadResult::NotCompleted, generation + ); + recordFailure( + *stillWatched, + upload.change.path, + upload.destination, + {.reason = SharedData::UploadFailureReason::NotQueued, .detail = queued.error()} + ); + } finishUpload(upload, UploadOutcome::NotUploaded); } /** * @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, 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), 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) { 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) @@ -1988,6 +2650,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 @@ -1995,12 +2661,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); @@ -2071,7 +2742,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 @@ -2274,9 +2945,59 @@ namespace FileTracking return impl_->discard(instanceId, path); } - void Tracker::answer(Ids::QuestionId const& questionId, SharedData::ServerChangedAnswer 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_->answer(questionId, answer); + impl_->removeAllOrphanedCopies(); } void Tracker::stopAll() 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..f8e24f3c --- /dev/null +++ b/backend/source/backend/file_tracking/watch_progress_meter.cpp @@ -0,0 +1,204 @@ +#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; + } + + 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); + 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, + std::optional generation + ) + { + 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, + std::optional generation + ) + { + const auto upload = activeUpload(path, generation); + if (upload == uploads_.end()) + return; + 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) + : 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/source/backend/session_file_tracking.cpp b/backend/source/backend/session_file_tracking.cpp index 81bc26f0..d39907bf 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 @@ -8,7 +10,9 @@ #include #include #include +#include #include +#include #include #include @@ -28,6 +32,8 @@ #include #include #include +#include +#include namespace { @@ -151,6 +157,55 @@ namespace return std::filesystem::is_directory(std::filesystem::symlink_status(path.native(), statusError)); } + /** + * @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. */ @@ -230,13 +285,17 @@ 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, OnUploadProgress onProgress) override { const bool isDirectory = isDirectoryLocally(request.source); const auto sent = std::make_shared(); + // 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]( + [&request, &sent, &queuedAs, &observer]( OperationQueue& queue, SecureShell::SftpSession& channel, Ids::OperationId const& operationId ) { @@ -253,18 +312,22 @@ 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) { 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)) + request.destination, endOfUpload(session_, request, isDirectory, sent, std::move(onEnded)) ) ); if (queued && queuedAs) @@ -346,30 +409,39 @@ 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, + 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(); @@ -382,11 +454,15 @@ 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); queue.leaveChangedDestinationsToOwner(cardId); + // 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(), @@ -449,12 +525,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 @@ -463,37 +540,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, @@ -504,72 +578,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); + } } ); } @@ -876,17 +922,75 @@ void Session::registerRpcFileTracker() } ); - on(name("answer")) + // 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& questionId, SharedData::ServerChangedAnswer answer - ) + [withTracker](RpcHelper::RpcOnce&& reply, std::string const& instanceId) { if (const auto self = withTracker(reply)) - { - self->tracker_->answer(Ids::makeQuestionId(questionId), answer); - reply({{"success", true}}); - } + replyWith( + reply, + self->tracker_->removeOrphanedCopy(Ids::makeInstanceId(instanceId)), + nlohmann::json::object() + ); } ); } 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/backend/source/backend/sftp/bulk_transfer_operation.cpp b/backend/source/backend/sftp/bulk_transfer_operation.cpp index 66c83ae1..76192e29 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)} @@ -41,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(); @@ -53,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."} ); @@ -99,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}); } @@ -108,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."} ); @@ -117,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()); @@ -136,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."} ); @@ -165,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." @@ -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 { @@ -329,6 +353,7 @@ std::expected BulkTransferOperation::stepSlo Log::log( failureLevelOf(error), + common_.failureToast, "{}: Transfer failed for: {}: {}", logName(), slot.paths.remote.value(), @@ -338,11 +363,15 @@ 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) { - 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; @@ -701,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_file_problem.cpp b/backend/source/backend/sftp/local_file_problem.cpp new file mode 100644 index 00000000..dbd74672 --- /dev/null +++ b/backend/source/backend/sftp/local_file_problem.cpp @@ -0,0 +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/source/backend/sftp/local_scan_operation.cpp b/backend/source/backend/sftp/local_scan_operation.cpp index 461a0ce2..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 @@ -37,6 +38,7 @@ LocalScanOperation::LocalScanOperation(ScanOperationOptions options) , ignoreHidden_{options.ignoreHidden} , buildTree_{options.buildTree} , excludedSuffixes_{std::move(options.excludedSuffixes)} + , failureToast_{options.failureToast} {} LocalScanOperation::~LocalScanOperation() = default; @@ -91,12 +93,17 @@ 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) ); return enterErrorState>( - {.type = ErrorType::FilesystemError, .extraInfo = Utility::errorMessage(error)} + { + .type = ErrorType::FilesystemError, + .extraInfo = Utility::errorMessage(error), + .localProblem = localFileProblemOf(error), + } ); }; std::error_code statusError{}; @@ -233,10 +240,17 @@ 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)} + { + .type = ErrorType::FilesystemError, + .extraInfo = Utility::errorMessage(errorCode), + .localProblem = localFileProblemOf(errorCode), + } ); } @@ -269,7 +283,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 +308,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 +324,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 609ad5c1..da5e3f66 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; @@ -215,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) @@ -229,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}; } } @@ -270,8 +346,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 +403,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 +450,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"))); @@ -569,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() ); @@ -580,6 +665,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 +689,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); @@ -613,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()) @@ -785,8 +882,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) @@ -827,7 +933,7 @@ bool OperationQueue::workQueue(std::dequestate() == SharedData::OperationState::Canceled @@ -873,6 +981,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); } ); }; @@ -1284,23 +1392,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); } ); }; @@ -1413,23 +1520,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); } ); }; @@ -1583,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 @@ -1596,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; @@ -1604,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{}); @@ -1627,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 ) @@ -1634,23 +1742,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); } ); }; @@ -1659,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_, @@ -1681,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{ @@ -1697,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_, @@ -1730,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}); } } @@ -1741,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. @@ -1768,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() @@ -1809,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{ @@ -1822,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 7048c043..947ee758 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) @@ -79,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(); @@ -119,6 +134,7 @@ std::expected UploadOperati { Log::log( failureLevelOf(prepareResult.error()), + options_.failureToast, "UploadOperation: Failed to prepare operation: {}", prepareResult.error().toString() ); @@ -136,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."); @@ -150,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( @@ -164,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()} ); @@ -180,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()) @@ -199,7 +225,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; @@ -226,6 +254,7 @@ std::expected UploadOperati { Log::log( failureLevelOf(finalizeResult.error()), + options_.failureToast, "UploadOperation: Failed to finalize operation: {}", finalizeResult.error().toString() ); @@ -260,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}); } @@ -272,10 +301,9 @@ 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, - }); + 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; } return localFile_.gcount(); @@ -297,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(); @@ -316,16 +344,21 @@ std::expected UploadOperation::handleSymlinkInStra if (readError) { Log::error( + options_.failureToast, "UploadOperation: Cannot read the symlink '{}': {}.", 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) { - 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()}); } @@ -342,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()}); } @@ -354,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()) { @@ -377,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(); @@ -387,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}); } @@ -408,20 +445,22 @@ 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."); - return enterErrorState({.type = ErrorType::SourceFileNotGood}); + Log::error(options_.failureToast, "UploadOperation: The local file ended before its expected size was read."); + return enterErrorState(localFileError(ErrorType::SourceFileNotGood, localFile_.lastError())); } leftToUpload_ -= readCount; 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()}); } @@ -438,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. @@ -490,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(); @@ -499,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()}); } @@ -534,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()}); } @@ -587,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{ @@ -614,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()}); } @@ -631,28 +683,37 @@ 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()); - return std::unexpected(Error{.type = ErrorType::FileNotFound}); + 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) { Log::error( - "UploadOperation: Cannot inspect '{}': {}", options_.localPath.toUtf8(), Utility::errorMessage(statusError) - ); - return std::unexpected( - Error{.type = ErrorType::FileStatFailed, .extraInfo = 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) && @@ -666,13 +727,23 @@ 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()); - return std::unexpected(Error{.type = ErrorType::FileNotFound}); + 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()); - return std::unexpected(Error{.type = ErrorType::NotAFile}); + 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} + ); } // Taken before the file is opened, so a change while it is read differs from it. @@ -680,8 +751,13 @@ 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( + options_.failureToast, + "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()); @@ -689,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 {}; @@ -699,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) @@ -726,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()); } @@ -751,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, @@ -853,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}); } @@ -875,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(); @@ -893,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, @@ -923,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/main.cpp b/backend/test/backend/main.cpp index a6fc788e..c5f68c24 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" @@ -46,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_batching_remote.hpp b/backend/test/backend/test_batching_remote.hpp index 0ea7cd43..5c788830 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)); @@ -158,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 { @@ -211,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); }; } @@ -223,7 +250,8 @@ namespace Test .destination = remote("/srv/" + name), .createMissingDirectories = createMissingDirectories, }, - recordUploadAs(name) + recordUploadAs(name), + recordProgressAs(name) ) .has_value()); } @@ -312,7 +340,8 @@ namespace Test .destination = remote("/srv/directory"), .createMissingDirectories = true, }, - recordUploadAs("directory") + recordUploadAs("directory"), + {} ) .has_value()); @@ -411,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) @@ -489,7 +562,8 @@ namespace Test .createMissingDirectories = true, .continuesPartialOf = remote("/srv/c.txt"), }, - recordUploadAs("renamed.txt") + recordUploadAs("renamed.txt"), + {} ) .has_value()); @@ -506,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"); 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..6428b08c --- /dev/null +++ b/backend/test/backend/test_bulk_entry_progress.hpp @@ -0,0 +1,346 @@ +#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( + {.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); + 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( + {.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( + {.operationId = {}, .min = 1u, .max = 1u, .current = 1u, .bytesPerSecond = 0}, 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); + } +} diff --git a/backend/test/backend/test_conflict_list.hpp b/backend/test/backend/test_conflict_list.hpp new file mode 100644 index 00000000..8d9f3257 --- /dev/null +++ b/backend/test/backend/test_conflict_list.hpp @@ -0,0 +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.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); + 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()); + } +} diff --git a/backend/test/backend/test_failure_list.hpp b/backend/test/backend/test_failure_list.hpp new file mode 100644 index 00000000..de28f512 --- /dev/null +++ b/backend/test/backend/test_failure_list.hpp @@ -0,0 +1,353 @@ +#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, 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")); + 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()); + } +} diff --git a/backend/test/backend/test_failure_reason.hpp b/backend/test/backend/test_failure_reason.hpp new file mode 100644 index 00000000..ec03c32d --- /dev/null +++ b/backend/test/backend/test_failure_reason.hpp @@ -0,0 +1,405 @@ +#pragma once + +#include + +#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, 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( + 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()); + } + } +} diff --git a/backend/test/backend/test_file_tracking.hpp b/backend/test/backend/test_file_tracking.hpp index 8ed54a7f..44ed11a0 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,19 @@ #include +#include #include +#include +#include #include #include #include #include +#include #include +#include +#include +#include using namespace FileTracking; using namespace std::chrono_literals; @@ -68,6 +77,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 +605,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.at("instanceId"), inst->instanceId()); + EXPECT_EQ(after.at("createdAt"), before.at("createdAt")); + ASSERT_TRUE(after.contains("deadAt")); + 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()); + 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.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) + { + 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 // ========================================================================= @@ -1063,4 +1167,363 @@ 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))); + } + + // ---- 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 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..4298ef88 --- /dev/null +++ b/backend/test/backend/test_local_file_problem.hpp @@ -0,0 +1,415 @@ +#pragma once + +#include +#include +#include +#include +#include "real_server_tests.hpp" + +#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 +#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 + + 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(Operation& operation, int maxIterations = 2000) + { + auto status = Operation::WorkStatus::MoreWork; + for (int iteration = 0; iteration < maxIterations && status != Operation::WorkStatus::Complete; ++iteration) + { + const auto worked = operation.work(); + if (!worked) + return worked; + status = *worked; + } + 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}; + }; + + 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) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + + const auto upload = uploadShrinking(sftp, 1024u * 1024u, false); + + 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) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [session, sftp] = createSftpSession(serverStartResult->port); + + 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({ + { + .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); + + 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()); + } +} 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) 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/backend/test/backend/test_operation_queue.hpp b/backend/test/backend/test_operation_queue.hpp index 6f80cd8c..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,14 +19,21 @@ #include #include +#include #include #include +#include #include +#include #include +#include +#include #include #include #include +#include #include +#include #include #ifndef _WIN32 @@ -336,6 +344,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_{ @@ -555,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) @@ -1271,6 +1532,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); diff --git a/backend/test/backend/test_tracker.hpp b/backend/test/backend/test_tracker.hpp index c2da0f77..9614f02c 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" @@ -17,12 +18,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -55,6 +58,7 @@ namespace Test FileTracking::UploadRequest upload{}; OnEnded onEnded{}; OnUploadEnded onUploadEnded{}; + OnUploadProgress onUploadProgress{}; bool ended{false}; }; @@ -90,8 +94,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 +107,7 @@ namespace Test .upload = request, .onEnded = {}, .onUploadEnded = std::move(onEnded), + .onUploadProgress = std::move(onProgress), }); return {}; } @@ -180,40 +188,120 @@ 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 showConflict(SharedData::WatchConflict const& conflict) override + { + conflicts.insert_or_assign(conflict.conflictId.value(), conflict); + shownConflicts.push_back(conflict); + events.push_back("conflict " + conflict.conflictId.value()); + } + + void removeConflicts(SharedData::RemovedConflicts const& removal) override + { + 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 + { + failures.insert_or_assign(failure.failureId.value(), failure); + events.push_back("failure " + failure.failureId.value()); } - void ask(SharedData::ServerChangedQuestion const& question) override + void removeFailures(SharedData::RemovedFailures const& removal) override { - questions.push_back(question); + 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 showOrphanedCount(std::size_t count) override + void showWatchProgress(SharedData::WatchProgress const& shown) override { - orphanedCounts.push_back(count); + watchProgress.push_back(shown); + watchProgressShownAt.push_back(std::chrono::steady_clock::now()); + events.push_back("progress " + shown.watchId.value()); } - void showProgress(SharedData::TrackerProgress const& shown) override + void showOrphanedCopies(SharedData::OrphanedCopies const& copies) override { - progress.push_back(shown); + orphanedCopies.push_back(copies); + events.push_back("orphans " + std::to_string(copies.copies.size())); } 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{}; + /** @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{}; + std::vector orphanedCopies{}; + /** @brief Every call, in order: what it was and the id it was about. */ + std::vector events{}; + }; + + /** + * @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; @@ -375,7 +463,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, @@ -613,26 +703,176 @@ 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 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)) + 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) @@ -756,7 +996,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()); @@ -790,12 +1030,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()); @@ -809,23 +1049,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]() @@ -834,22 +1080,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()); @@ -863,20 +1108,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 @@ -902,13 +1146,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})); } @@ -916,63 +1159,283 @@ 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})); - tracker_->answer(view_->questions.back().questionId, SharedData::ServerChangedAnswer::Keep); - ASSERT_TRUE(runUntilProgress({.pending = 0u, .done = 1u, .waitingForAnswer = 0u})); + ASSERT_TRUE(runUntilProgress(watchId, {.pending = 1u, .waitingForAnswer = 1u})); + EXPECT_EQ(lastProgressOf(watchId)->changesWaiting, 1u); + tracker_->answerConflict(onlyConflict().conflictId, SharedData::ConflictResolution::KeepServers); + 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 !view_->progress.empty() && lastProgress().pending == 0u; + 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 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 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)); + 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, AnAnswerToAnotherQuestionChangesNothing) + TEST_F(TrackerTest, AnAnswerToAnUnknownConflictChangesNothing) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); @@ -986,16 +1449,332 @@ namespace Test } )); completeUploads(); + ASSERT_EQ(view_->conflicts.size(), 1u); + tracker_->answerConflict(Ids::generateConflictId(), SharedData::ConflictResolution::ReplaceOnServer); + tracker_->answerAllConflicts(Ids::generateWatchId(), SharedData::ConflictResolution::ReplaceOnServer); + + 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()})); + 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()); + 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(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{ + "removeConflicts " + mainConflict.value() + " " + utilConflict.value(), + "removeFailures " + failureId, + "progress " + watchId.value(), + "entry " + watchId.value() + " StoppedByUser", + "orphans 0", + }) + ); + 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); + 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_); + 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()})); + // 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 + 1u); + } + + 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) + { + 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) @@ -1084,7 +1863,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"); @@ -1354,41 +2133,356 @@ 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) + { + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + const auto renamed = opened->localPath.native().parent_path() / "renamed.txt"; + + std::filesystem::rename(opened->localPath.native(), renamed); + ASSERT_TRUE(runUntil( + [this]() + { + return !view_->ended.empty(); + }, + 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}); + + EXPECT_EQ(view_->ended.front().reason, SharedData::WatchEndReason::CopyGone); + 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)); + 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) + { + const auto opened = open("/srv/notes.txt", /*watch=*/true); + ASSERT_TRUE(opened.has_value()); + const auto watchId = onlyEntry().watchId; + + tracker_->stop(watchId); + + 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); + // 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()); + } + + TEST_F(TrackerTest, StoppingAnUploadedOriginalLeavesItAndFreesIt) + { + ASSERT_TRUE( + tracker_->uploadAndWatch(local(root_ / "local" / "notes.txt"), remote("/srv/notes.txt")).has_value() + ); + + tracker_->stop(onlyEntry().watchId); + + 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", "orphans 0" + }) + ); + 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(), 3u); + EXPECT_TRUE(view_->failures.empty()); + EXPECT_EQ(uploadsTo("/srv/notes.txt"), 2u); + tracker_->stop(watchId); + EXPECT_EQ(view_->events.size(), 3u); + } + + 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", + "orphans 0", + }) + ); + 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(), 4u); + } + + 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, ADownloadedCopyRenamedAwayEndsItsWatchAndStaysForTheCleanup) + TEST_F(TrackerTest, OpenRecordsWhereTheCopyCameFromForTheOrphanList) { - const auto opened = open("/srv/notes.txt", /*watch=*/true); + const auto opened = open("/srv/docs/notes.txt", /*watch=*/false); ASSERT_TRUE(opened.has_value()); - const auto renamed = opened->localPath.native().parent_path() / "renamed.txt"; - - std::filesystem::rename(opened->localPath.native(), renamed); - ASSERT_TRUE(runUntil( - [this]() - { - return !view_->ended.empty(); - }, - FileTracking::copyReturnTime + std::chrono::seconds{2} - )); - write(renamed, "edited after the rename"); - runFor(std::chrono::milliseconds{1000}); - - EXPECT_EQ(view_->ended.front().reason, SharedData::WatchEndReason::CopyGone); - EXPECT_TRUE(view_->entries.empty()); - EXPECT_EQ(uploadsTo("/srv/notes.txt"), 0u); - EXPECT_TRUE(remote_->of("rename").empty()); - EXPECT_TRUE(std::filesystem::exists(renamed)); - EXPECT_EQ(instances_->countOrphaned(), 1u); + 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, StoppingADownloadedCopyEndsItsInstance) + TEST_F(TrackerTest, AStoppedCopyIsListedOnlyAsStoppedUntilItsStoppedWatchIsRemoved) { const auto opened = open("/srv/notes.txt", /*watch=*/true); ASSERT_TRUE(opened.has_value()); @@ -1396,24 +2490,124 @@ namespace Test tracker_->stop(watchId); - EXPECT_TRUE(view_->entries.empty()); - EXPECT_EQ(view_->removed, std::vector{watchId.value()}); - EXPECT_EQ(instances_->findInstance(opened->instanceId.value()), nullptr); - EXPECT_FALSE(view_->orphanedCounts.empty()); - EXPECT_TRUE(open("/srv/notes.txt", /*watch=*/true).has_value()); + 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, StoppingAnUploadedOriginalLeavesItAndFreesIt) + 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)); + } - tracker_->stop(onlyEntry().watchId); + 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); + } - EXPECT_TRUE(view_->entries.empty()); - 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, 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) @@ -1470,26 +2664,45 @@ 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(); + + 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(lastProgress(), SharedData::TrackerProgress{}); + // 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(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()); } @@ -1537,11 +2750,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()); } @@ -1561,11 +2774,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) @@ -1852,7 +3065,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); @@ -1882,7 +3095,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"); @@ -1947,12 +3160,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); } @@ -1983,7 +3196,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")); } @@ -2015,7 +3228,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) @@ -2050,7 +3263,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) @@ -2087,7 +3300,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) @@ -2114,7 +3327,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) @@ -2147,7 +3360,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) @@ -2216,11 +3429,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) @@ -2322,7 +3535,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) @@ -2354,8 +3567,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"); @@ -2363,17 +3577,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"); @@ -2388,16 +3603,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) @@ -2405,8 +3615,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"); @@ -2428,22 +3639,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); } @@ -2515,19 +3728,56 @@ 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(source / "new.cpp", "new"); + 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); + + // 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); }}; - runFor(std::chrono::milliseconds{1000}); + 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]() + { + return !view_->failures.empty(); + } + )); + 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(), file); + EXPECT_EQ(failure.remotePath.value(), "/srv/project/src/main.cpp"); + EXPECT_EQ(failure.reason, SharedData::UploadFailureReason::PermissionDeniedLocally); + EXPECT_TRUE(failure.detail.has_value()); } #endif @@ -2554,7 +3804,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) @@ -2715,16 +3965,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) @@ -2783,7 +4034,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) @@ -2827,7 +4078,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) @@ -3093,4 +4344,457 @@ 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); + EXPECT_EQ(view_->failureRemovals, std::vector{2u}) << "one message for all retried failures"; + 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()})); + 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); + } + + 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); + } + + 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); + } } 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..b54e51a8 --- /dev/null +++ b/backend/test/backend/test_watch_progress_meter.hpp @@ -0,0 +1,563 @@ +#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, 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); + 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()); + } +} 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 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 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/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/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/file_tracking/attention_state.hpp b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp new file mode 100644 index 00000000..e5c152e4 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/attention_state.hpp @@ -0,0 +1,104 @@ +#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(AttentionCounts const& counts, std::chrono::milliseconds now) + { + 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}; + } + + /** + * @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() + { + unseen_ = false; + } + + /** + * @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() + { + counts_ = {}; + lastPulse_.reset(); + unseen_ = false; + } + + private: + AttentionCounts counts_{}; + std::optional lastPulse_{}; + bool unseen_{false}; + }; +} 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..2ab64d90 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/conflicts_tab.hpp @@ -0,0 +1,23 @@ +#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, 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/detail_tabs.hpp b/frontend/include/frontend/session_components/file_tracking/detail_tabs.hpp new file mode 100644 index 00000000..db550357 --- /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/dock_tab_attention.hpp b/frontend/include/frontend/session_components/file_tracking/dock_tab_attention.hpp new file mode 100644 index 00000000..1e6b3443 --- /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/failures_tab.hpp b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp new file mode 100644 index 00000000..257be1a3 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/failures_tab.hpp @@ -0,0 +1,25 @@ +#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, 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/file_tracking_texts.hpp b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp new file mode 100644 index 00000000..1aa6b5ef --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/file_tracking_texts.hpp @@ -0,0 +1,184 @@ +#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 "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. + */ + 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..3bdc5d41 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/in_flight_rows.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#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(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. + */ + std::vector> const& rows() const + { + return rows_; + } + + /** + * @return The row of @p key, nothing when it is not in flight. + */ + std::optional rowOf(KeyT const& key) const + { + 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)); + } + + /** + * @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: + 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/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..1c427eec --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/item_list_box.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +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. + */ + 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 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][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); + + /** + * @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/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..906941e3 --- /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..3ca8b489 --- /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..c5f20b8a --- /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/panel_state.hpp b/frontend/include/frontend/session_components/file_tracking/panel_state.hpp new file mode 100644 index 00000000..676b36f9 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/panel_state.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#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 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. + */ + 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/problem_list.hpp b/frontend/include/frontend/session_components/file_tracking/problem_list.hpp new file mode 100644 index 00000000..e78e8827 --- /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 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. + * @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. + */ + ConstIterator begin() const + { + return problems_.cbegin(); + } + + /** + * @return Past the oldest problem. + */ + ConstIterator 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/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..0f5228a7 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/stop_reason_strip.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @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/sync_progress_block.hpp b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp new file mode 100644 index 00000000..27efa0f8 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/sync_progress_block.hpp @@ -0,0 +1,24 @@ +#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=). 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. + */ + 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 new file mode 100644 index 00000000..1ad16b0f --- /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 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 + { + 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, onConflictsRemoved, onFailure, onFailuresRemoved, 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..2fdeceb0 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_actions.hpp @@ -0,0 +1,71 @@ +#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 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. */ + 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..e0f68794 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_items.hpp @@ -0,0 +1,142 @@ +#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 The conflicts of the page shown, newest first, as TrackingModel::conflictsOf orders them. */ + Nui::Observed>> conflicts{}; + /** @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)}; + }; + + /** + * @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_model.hpp b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp new file mode 100644 index 00000000..32a69855 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_model.hpp @@ -0,0 +1,209 @@ +#pragma once + +#include +#include +#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); + + 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. + */ + 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, as the newest of its watch. Kept even while its + * watch is not known yet. + */ + void showConflict(SharedData::WatchConflict const& conflict); + + /** + * @brief Forgets the conflicts @p conflictIds, they were answered or dropped. Unknown ids are skipped. + */ + 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), + * as the newest of its watch. + */ + void showFailure(SharedData::UploadFailure const& failure); + + /** + * @brief Forgets the failures @p failureIds, they went through, were retried or dismissed. Unknown ids are + * skipped. + */ + void removeFailures(std::vector const& failureIds); + + /** + * @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 At most @p count conflicts of @p watchId from rank @p offset on, the last shown first. + */ + std::vector + conflictsOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const; + + /** + * @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, std::size_t offset, std::size_t count) 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. 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. Failures of a watch not known yet are left + * out. + */ + 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 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_{}; + 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 new file mode 100644 index 00000000..682793a8 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/tracking_store.hpp @@ -0,0 +1,201 @@ +#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. A new watch that + * is not stopped is selected while nothing is. + */ + 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 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 conflicts @p conflictIds and updates each watch they belonged to once. + */ + void removeConflicts(std::vector const& conflictIds); + + /** + * @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 failures @p failureIds and updates each watch they belonged to once. + */ + 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 + * 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 on the first page, which shows it. + * @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/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..eec5a728 --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/watch_detail.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace FileTracking +{ + /** + * @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/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..e6d326ff --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking/watch_header.hpp @@ -0,0 +1,24 @@ +#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..4e6f50a5 --- /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/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/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/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/CMakeLists.txt b/frontend/source/frontend/CMakeLists.txt index fa02c681..4e9f36e0 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 @@ -157,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 @@ -281,6 +300,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" @@ -330,8 +353,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/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 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.cpp b/frontend/source/frontend/session.cpp index ab21e80a..9e0112d6 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, @@ -207,6 +210,21 @@ struct Session::Implementation ); fileExplorerPanel.dropLayoutMetadata(sessionLayoutId); + + fileTrackingPanel.setRevealOnServer( + [this](Paths::RemotePath const& path) + { + 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) + return side->path(path.value()); + side->navigateAndSelect(parent->value(), name->value()); + } + ); } /** 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/session_components/file_tracking.cpp b/frontend/source/frontend/session_components/file_tracking.cpp index a1fab749..b527e286 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) - { - 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) + explicit Implementation(Params params) + : confirmDialog{params.confirmDialog} + , attention{std::move(params.sessionLayoutId)} + , store{FileTracking::translatedTexts()} + , client{ + store, + [](SharedData::EndedWatches const& ended) + { + notifyEnded(ended); + } + } { - 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::DockTabAttention attention; + FileTracking::TrackingStore store; + FileTracking::TrackerClient client; + 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,318 @@ 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, + entry->sizeBytes, + 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, 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); + }, + .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) + { + 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. - */ - ScriptNuiComponents::ResizableTable::TableCell switchCell( - Ids::WatchId const& watchId, - std::shared_ptr> shown, - SharedData::WatchOption option, - std::shared_ptr> downloadedCopy = nullptr - ) - { - 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. + * @brief The detail of what the outliner selects, the placeholder while that is nothing or not known. */ - 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) + Nui::ElementRenderer renderDetail() { - 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 +417,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 +427,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 +436,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 +463,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/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..dbdae21e --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/conflicts_tab.cpp @@ -0,0 +1,125 @@ +#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(), + "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), + 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, + TrackingActions const& actions, + std::shared_ptr const& item + ) + { + 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, + .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"}( + 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/detail_tabs.cpp b/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp new file mode 100644 index 00000000..d275900f --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/detail_tabs.cpp @@ -0,0 +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 { + 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(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) + { + 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 + } +} 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..0cf6c38f --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/dock_tab_attention.cpp @@ -0,0 +1,99 @@ +#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) + : panelId{std::move(panelId)} + {} + + 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) + : impl_{std::make_unique(std::move(panelId))} + {} + + ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(DockTabAttention); + + void DockTabAttention::show(AttentionCounts const& counts) + { + if (const auto attention = impl_->state.update(counts, steadyNow()); attention) + impl_->forward(*attention); + } + + bool DockTabAttention::unseen() const + { + return impl_->state.unseen(); + } + + void DockTabAttention::acknowledge() + { + impl_->state.acknowledge(); + } + + void DockTabAttention::reset() + { + impl_->state.reset(); + impl_->forward(Attention{}); + } +} 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..3de80272 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/failures_tab.cpp @@ -0,0 +1,157 @@ +#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), + "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), + 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, + TrackingActions const& actions, + std::shared_ptr const& item + ) + { + 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, + .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"}( + Nui::range(item->failures), + [&actions, keepAlive = item](long long, std::shared_ptr const& failure) -> Nui::ElementRenderer + { + return failureRow(actions, *failure); + } + ) + ); + // clang-format on + } +} 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..facd95cd --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/file_tracking_texts.cpp @@ -0,0 +1,274 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + 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 fmt::format(fmt::runtime(texts.counts.failed), count); + } + + std::string stopSideText(FileTrackingTexts const& texts, SharedData::WatchStopReason reason) + { + 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 stopReasonText(FileTrackingTexts const& texts, SharedData::WatchStop const& stop) + { + 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 failureReasonText(FileTrackingTexts const& texts, SharedData::UploadFailureReason reason) + { + 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 conflictLineText(FileTrackingTexts const& texts, SharedData::WatchConflict const& conflict) + { + 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 clockTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds) + { + const auto local = shiftedSeconds(secondsSinceEpoch, utcOffsetSeconds); + return hoursAndMinutes(local - floorDivide(local, secondsPerDay) * secondsPerDay); + } + + std::string dateTime(std::uint64_t secondsSinceEpoch, std::int64_t utcOffsetSeconds) + { + 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(std::uint64_t secondsSinceEpoch) + { + 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(std::uint64_t secondsSinceEpoch) + { + return clockTime(secondsSinceEpoch, localUtcOffset(secondsSinceEpoch)); + } + + std::string localDateTime(std::uint64_t secondsSinceEpoch) + { + return dateTime(secondsSinceEpoch, localUtcOffset(secondsSinceEpoch)); + } + + std::string sizeText(std::uint64_t bytes) + { + const auto limited = + std::min(bytes, static_cast(std::numeric_limits::max())); + return Utility::formatBytes(static_cast(limited)); + } + + std::string pathWithin(Paths::LocalPath const& watched, Paths::LocalPath const& path) + { + 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/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..60340bf4 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/item_list_box.cpp @@ -0,0 +1,138 @@ +#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; + using namespace Nui::Attributes; + 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(4u); + 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; + } + ) + ); + } + 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))}( + 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", + 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 + } + + Nui::ElementRenderer itemListBoxButton(ItemListBoxButtonOptions options) + { + 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, + }); + } +} 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..1808077c --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/options_tab.cpp @@ -0,0 +1,164 @@ +#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 Disabled on a downloaded copy, the tracker keeps it off there. */ + 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 + optionRow(TrackingActions const& actions, std::shared_ptr const& item, OptionSwitch const& option) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Attributes::title; + 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, + .attributes = { + role = "switch", + "aria-checked"_attr = Nui::observe(option.enabled).generate( + [](bool enabled) + { + return std::string{enabled ? "true" : "false"}; + } + ), + "aria-label"_attr = option.label, + disabled = Nui::observe(item->downloadedCopy).generate(lockedNow), + }, + .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"}( + text{Nui::observe(item->downloadedCopy).generate( + textWhileLocked(option.description, language->get("fileTracking", "optionNeverForCopy")) + )}() + ) + ); + // clang-format on + } + } + + Nui::ElementRenderer optionsTab( + [[maybe_unused]] TrackingStore& store, + TrackingActions const& actions, + std::shared_ptr const& item + ) + { + using namespace Nui::Elements; + using namespace Nui::Attributes; + using Nui::Elements::div; + + const auto switches = optionSwitchesOf(*item); + + // clang-format off + return div{class_ = "file-tracking-options-tab"}( + 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/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..ae82e99f --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/orphan_detail.cpp @@ -0,0 +1,160 @@ +#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, + TrackingActions const& actions, + std::shared_ptr const& item + ) + { + 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/orphan_row.cpp b/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp new file mode 100644 index 00000000..0e7f1fa6 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/orphan_row.cpp @@ -0,0 +1,85 @@ +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include + +#include + +#include +#include + +namespace FileTracking +{ + namespace + { + 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/outliner.cpp b/frontend/source/frontend/session_components/file_tracking/outliner.cpp new file mode 100644 index 00000000..83ccc8a3 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/outliner.cpp @@ -0,0 +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 +{ + namespace + { + 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 + } +} 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..4f79ca26 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/status_glyph.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace FileTracking +{ + 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::status_inactive(); + 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 glyph(Utility::enumToString(status), iconOf(status)); + } + + Nui::ElementRenderer orphanGlyph() + { + return glyph("Orphaned", Ui5Icons::document()); + } +} 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..c99de6c3 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/stop_reason_strip.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +namespace FileTracking +{ + 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) + { + 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{}; + } + ), + "data-ft-stop-text"_attr = Nui::observe(item->stopText).generate( + [item]() + { + return item->stopText.value(); + } + ), + hidden = Nui::observe(item->stop).generate( + [item]() + { + return !item->stop.value().has_value(); + } + ), + }, + }); + // clang-format on + } +} 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..0f3b761a --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/sync_progress_block.cpp @@ -0,0 +1,440 @@ +#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; + 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 + 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}; + const auto changes = progress.lastSync->changes; + return { + .text = formatted(texts.upToDateLastUpload, localClockTime(progress.lastSync->endedAt)), + .bar = changes > 0u ? std::optional{BarValues{.current = changes, .maximum = changes}} + : std::nullopt, + }; + } + + /** + * @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 @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. + */ + 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(); + } + ), + "data-ft-bytes-done"_attr = bytesAttributeValue(row->bytes), + "data-ft-bytes-total"_attr = bytesAttributeValue(row->totalBytes), + }, + }) + ); + // 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, + "data-ft-bytes-done"_attr = bytesAttributeValue(item->bytesDone), + "data-ft-bytes-total"_attr = bytesAttributeValue(item->bytesTotal), + }, + }) + ), + 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( + TrackingStore& store, + [[maybe_unused]] TrackingActions const& actions, + std::shared_ptr const& item + ) + { + 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 + } +} 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..d34c2cfc --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/tracker_client.cpp @@ -0,0 +1,382 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#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{}; + std::vector listeners{}; + /** @brief Set from the first event after a sync until the sync it scheduled ran. */ + bool syncScheduled{false}; + 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(); + } + + /** + * @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(); + } + + /** + * @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 a sync is scheduled after it. + */ + template + void listenTo(std::string_view event, HandlerT handler) + { + listeners.push_back( + Nui::RpcClient::autoRegisterFunction( + rpcName(event), + lifetime.guarded( + [this, handler = std::move(handler)](PayloadT const& payload) + { + handler(payload); + scheduleSync(); + } + ) + ) + ); + } + + 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( + "onConflictsRemoved", + [this](SharedData::RemovedConflicts const& removed) + { + store->removeConflicts(removed.conflictIds); + } + ); + listenTo( + "onFailure", + [this](SharedData::UploadFailure const& failure) + { + store->showFailure(failure); + } + ); + listenTo( + "onFailuresRemoved", + [this](SharedData::RemovedFailures const& removed) + { + store->removeFailures(removed.failureIds); + } + ); + listenTo( + "onWatchProgress", + [this](SharedData::WatchProgress const& progress) + { + store->showWatchProgress(progress); + } + ); + listenTo( + "onOrphanedCopies", + [this](SharedData::OrphanedCopies const& copies) + { + store->showOrphanedCopies(copies); + } + ); + } + }; + + 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(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( + 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( + 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( + 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(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) + { + impl_->forward("discard", instanceId.value(), localPath.toUtf8()); + } + + void TrackerClient::stop(Ids::WatchId const& watchId) + { + impl_->forward("stop", watchId.value()); + } + + void TrackerClient::setOption(Ids::WatchId const& watchId, SharedData::WatchOption option, bool enabled) + { + impl_->forward("setOption", watchId.value(), Utility::enumToString(option), enabled); + } + + void TrackerClient::answerConflict(Ids::ConflictId const& conflictId, SharedData::ConflictResolution resolution) + { + impl_->forward("answerConflict", conflictId.value(), Utility::enumToString(resolution)); + } + + void TrackerClient::answerAllConflicts(Ids::WatchId const& watchId, SharedData::ConflictResolution resolution) + { + impl_->forward("answerAllConflicts", watchId.value(), Utility::enumToString(resolution)); + } + + void TrackerClient::retryFailure(Ids::FailureId const& failureId) + { + impl_->forward("retryFailure", failureId.value()); + } + + void TrackerClient::dismissFailure(Ids::FailureId const& failureId) + { + impl_->forward("dismissFailure", failureId.value()); + } + + void TrackerClient::retryAllFailures(Ids::WatchId const& watchId) + { + impl_->forward("retryAllFailures", watchId.value()); + } + + void TrackerClient::dismissAllFailures(Ids::WatchId const& watchId) + { + impl_->forward("dismissAllFailures", watchId.value()); + } + + 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( + 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"); + } +} 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..7046b9b9 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/tracking_model.cpp @@ -0,0 +1,433 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + namespace + { + 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; + } + + /** + * @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)) + { + const auto list = problems.lists.find(watchId); + if (list == problems.lists.end()) + return {}; + return list->second.slice(offset, count); + } + + /** + * @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) + { + 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 + void showProblem( + ProblemsT& problems, + IdT const& problemId, + ValueT const& value, + std::uint64_t sequence, + IsKnownT const& isKnown + ) + { + const auto [watch, inserted] = problems.watches.try_emplace(problemId, value.watchId); + if (!inserted && watch->second != value.watchId) + { + removeFromWatch(problems, watch->second, problemId, isKnown(watch->second)); + watch->second = value.watchId; + } + if (problems.lists[value.watchId].show(problemId, value, sequence) && isKnown(value.watchId)) + ++problems.knownTotal; + } + + /** + * @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) + { + 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 + void dropWatch(ProblemsT& problems, Ids::WatchId const& watchId, bool known) + { + 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( + 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; + } + ); + } + } + + 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::showEntry(SharedData::TrackedEntry const& 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 known = entries_.erase(watchId) > 0u; + progress_.erase(watchId); + dropWatch(conflicts_, watchId, known); + dropWatch(failures_, watchId, known); + } + + void TrackingModel::showConflict(SharedData::WatchConflict const& conflict) + { + showProblem(conflicts_, conflict.conflictId, conflict, ++sequence_, knownIn(entries_)); + } + + void TrackingModel::removeConflicts(std::vector const& conflictIds) + { + removeProblems(conflicts_, conflictIds, knownIn(entries_)); + } + + void TrackingModel::showFailure(SharedData::UploadFailure const& failure) + { + showProblem(failures_, failure.failureId, failure, ++sequence_, knownIn(entries_)); + } + + void TrackingModel::removeFailures(std::vector const& failureIds) + { + removeProblems(failures_, failureIds, knownIn(entries_)); + } + + 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_ = {}; + failures_ = {}; + orphans_.clear(); + } + + SharedData::TrackedEntry const* TrackingModel::entryOf(Ids::WatchId const& watchId) const + { + const auto found = entries_.find(watchId); + return found == entries_.end() ? nullptr : &found->second; + } + + SharedData::WatchProgress const* TrackingModel::progressOf(Ids::WatchId const& watchId) const + { + const auto found = progress_.find(watchId); + return found == progress_.end() ? nullptr : &found->second; + } + + SharedData::OrphanedCopy const* TrackingModel::orphanOf(Ids::InstanceId const& instanceId) const + { + 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(Ids::ConflictId const& conflictId) const + { + return watchOf(conflicts_, conflictId); + } + + std::optional TrackingModel::watchOfFailure(Ids::FailureId const& failureId) const + { + return watchOf(failures_, failureId); + } + + std::vector + TrackingModel::conflictsOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const + { + return sliceOf(conflicts_, watchId, offset, count); + } + + std::vector + TrackingModel::failuresOf(Ids::WatchId const& watchId, std::size_t offset, std::size_t count) const + { + return sliceOf(failures_, watchId, offset, count); + } + + std::uint64_t TrackingModel::conflictCountOf(Ids::WatchId const& watchId) const + { + return countOf(conflicts_, watchId); + } + + std::uint64_t TrackingModel::failureCountOf(Ids::WatchId const& watchId) const + { + return countOf(failures_, watchId); + } + + std::uint64_t TrackingModel::conflictCount() const + { + return conflicts_.knownTotal; + } + + std::uint64_t TrackingModel::failureCount() const + { + return failures_.knownTotal; + } + + 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(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(OutlinerGroup group, std::string_view filter) const + { + 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(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(Ids::WatchId const& watchId, FileTrackingTexts const& texts) const + { + 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(Ids::WatchId const& watchId) const + { + 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 + { + ConflictList::ListedProblem const* newest = nullptr; + for (auto const& [watchId, list] : conflicts_.lists) + { + auto const* candidate = list.newest(); + if (candidate != nullptr && entries_.contains(watchId) && + (newest == nullptr || candidate->sequence > newest->sequence)) + newest = candidate; + } + 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 new file mode 100644 index 00000000..af762a26 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/tracking_store.cpp @@ -0,0 +1,866 @@ +#include +#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 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 + void syncRows( + Nui::Observed>>& observed, + std::map>& visible, + std::vector const& values, + ValueT ItemT::* valueOfItem, + KeyOfT keyOf, + MakeItemT makeItem, + bool rebuild + ) + { + std::map> shown{}; + std::vector> target{}; + target.reserve(values.size()); + for (auto const& value : values) + { + 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); + } + + /** + * @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{}; + /** @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) + : 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{ + 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{}; + /** @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{}; + + void selectNothing() + { + 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); + } + + /** + * @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, + watch.conflictRows, + model.conflictsOf(entry.watchId, offset, problemPageSize), + &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 + ); + } + + /** + * @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, + watch.failureRows, + model.failuresOf(entry.watchId, offset, problemPageSize), + &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 + ); + } + + 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(record, *entry, watchedMoved); + if (parts.failures || watchedMoved) + showFailures(record, *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))} + {} + + ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL_NO_MOVE(TrackingStore); + + 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 (inserted && !entry.stop && std::holds_alternative(impl_->selection.value())) + impl_->selection = Selection{entry.watchId}; + // 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(); + } + + void TrackingStore::removeEntry(Ids::WatchId const& watchId) + { + if (auto const* selected = std::get_if(&impl_->selection.value()); + selected != nullptr && *selected == watchId) + impl_->selectNothing(); + + 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); + } + + impl_->model.removeEntry(watchId); + if (group) + impl_->refreshMembers(*group); + if (heldInstance) + impl_->refreshOrphans(); + impl_->notifyAttention(); + } + + 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::removeConflicts(std::vector const& conflictIds) + { + 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(); + } + + 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::removeFailures(std::vector const& failureIds) + { + 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); + 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 + { + 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(Ids::WatchId const& watchId) const + { + const auto watch = impl_->watches.find(watchId); + return watch != impl_->watches.end() ? watch->second.item : nullptr; + } + + std::shared_ptr TrackingStore::orphanItem(Ids::InstanceId const& instanceId) const + { + const auto orphan = impl_->orphanItems.find(instanceId); + return orphan != impl_->orphanItems.end() ? orphan->second : nullptr; + } + + Nui::Observed& TrackingStore::selection() + { + return impl_->selection; + } + + void TrackingStore::select(Selection selection) + { + assignIfChanged(impl_->selection, std::move(selection)); + } + + 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(DetailTab tab) + { + impl_->detailTab = tab; + assignIfChanged(*impl_->detailTabKey, Utility::enumToString(tab)); + } + + std::shared_ptr> TrackingStore::detailTabKey() + { + return impl_->detailTabKey; + } + + Nui::Observed& TrackingStore::filter() + { + return impl_->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(OutlinerGroup group, bool expanded) + { + impl_->expanded[indexOf(group)] = expanded; + } + + bool TrackingStore::focusNewestConflict() + { + const auto newest = impl_->model.newestConflict(); + if (!newest) + return false; + select(newest->watchId); + setDetailTab(DetailTab::Conflicts); + setConflictPage(newest->watchId, 0); + return true; + } + + 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"), + }, + }; + } +} 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..3c3844e9 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/watch_detail.cpp @@ -0,0 +1,50 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace FileTracking +{ + Nui::ElementRenderer + watchDetail(TrackingStore& store, TrackingActions const& actions, std::shared_ptr const& item) + { + 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(), + "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), + syncProgressBlock(store, actions, item), + div{class_ = "file-tracking-detail-tabs-area"}( + detailTabs(store, actions, item) + ) + ); + // clang-format on + } +} 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..cdebc643 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/watch_header.cpp @@ -0,0 +1,288 @@ +#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 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, only Remove when the item left its local path. + */ + Nui::ElementRenderer headerButtons( + TrackingActions const* actions, + std::shared_ptr const& item, + std::optional const& stop + ) + { + 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 (stop) + { + // clang-format off + return div{class_ = "file-tracking-detail-buttons"}( + localItemLeft(*stop) ? Nui::nil() : 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, + TrackingActions const& actions, + std::shared_ptr const& item + ) + { + 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()); + } + ) + ), + 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 + } +} 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..084bcb92 --- /dev/null +++ b/frontend/source/frontend/session_components/file_tracking/watch_row.cpp @@ -0,0 +1,146 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace FileTracking +{ + 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) + { + 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 + } +} 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); 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(); 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(); 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..01dae755 100644 --- a/frontend/test/frontend/main.cpp +++ b/frontend/test/frontend/main.cpp @@ -1,11 +1,16 @@ +#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" #include "test_numbered_saves.hpp" #include "test_overwrite_decisions.hpp" -#include "test_row_order.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" #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..f2b35cd4 --- /dev/null +++ b/frontend/test/frontend/test_attention_state.hpp @@ -0,0 +1,257 @@ +#pragma once + +#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()); + } +} 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("/")), "/"); + } +} 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..2dd7d149 --- /dev/null +++ b/frontend/test/frontend/test_in_flight_rows.hpp @@ -0,0 +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}); + } +} 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/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}); + } +} 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()); - } -} diff --git a/frontend/test/frontend/test_tracking_model.hpp b/frontend/test/frontend/test_tracking_model.hpp new file mode 100644 index 00000000..2607cfc4 --- /dev/null +++ b/frontend/test/frontend/test_tracking_model.hpp @@ -0,0 +1,1079 @@ +#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)); + } + + 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) + { + 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.removeConflicts({Ids::makeConflictId("conflict")}); + + EXPECT_EQ(model.sideTextOf(watchIdOf("watch"), texts), "1 failed"); + + model.removeFailures({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.removeConflicts({Ids::makeConflictId("conflict")}); + + EXPECT_EQ(model.groupOf(watchIdOf("watch")), OutlinerGroup::NeedsYou); + EXPECT_EQ(model.statusOf(watchIdOf("watch")), WatchStatus::Failed); + + model.removeFailures({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(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); + 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 = allConflictsOf(model, "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 = allFailuresOf(model, "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.removeConflicts({Ids::makeConflictId("unknown")}); + model.removeFailures({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, TheConflictsOfAWatchStartWithTheLastShown) + { + 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(allConflictsOf(model, "watch")), (Names{"third", "second", "first"})); + EXPECT_EQ(model.conflictCountOf(watchIdOf("watch")), 3u); + EXPECT_EQ(model.conflictCountOf(watchIdOf("other")), 1u); + } + + TEST(TrackingModelTests, AConflictShownAgainMovesToTheFront) + { + using namespace TrackingModelTestDetail; + TrackingModel model; + model.showEntry(watchEntry("watch", "project")); + model.showConflict(conflictOf("alpha", "watch", 100u)); + model.showConflict(conflictOf("bravo", "watch", 100u)); + model.showConflict(conflictOf("charlie", "watch", 100u)); + model.showConflict(conflictOf("alpha", "watch", 200u)); + + EXPECT_EQ(conflictNamesOf(allConflictsOf(model, "watch")), (Names{"alpha", "charlie", "bravo"})); + EXPECT_EQ(model.conflictCountOf(watchIdOf("watch")), 3u); + EXPECT_EQ(model.conflictCount(), 3u); + } + + TEST(TrackingModelTests, TheFailuresOfAWatchStartWithTheLastShown) + { + 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(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; + 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, 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("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.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, AConflictShownAgainBecomesTheNewest) + { + 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)); + model.showConflict(conflictOf("bravo", "first", 100u)); + + ASSERT_TRUE(model.newestConflict().has_value()); + EXPECT_EQ(model.newestConflict()->conflictId, Ids::makeConflictId("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.removeConflicts({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{}); + } +} diff --git a/ids/include/ids/ids.hpp b/ids/include/ids/ids.hpp index 19875eab..d542da09 100644 --- a/ids/include/ids/ids.hpp +++ b/ids/include/ids/ids.hpp @@ -11,4 +11,5 @@ 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(ConflictId) +DEFINE_ID_TYPE(FailureId) 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}); } } 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/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_{}; + }; +} 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) 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", 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 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/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)) +} 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)) -} 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..47c1a636 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,12 @@ #include #include #include +#include #include #include +#include +#include #include namespace SharedData @@ -28,6 +31,19 @@ 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{}; + /** @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)) + BOOST_DESCRIBE_STRUCT( + TrackedEntry, + (), + (watchId, name, remotePath, options, uploading, downloadedCopy, localPath, isDirectory, stop, instanceId, sizeBytes) + ) } 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)) -} 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)) +} diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index c670cb3c..2063e03e 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -503,27 +503,111 @@ 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." + 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..7b3a905f 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -503,27 +503,111 @@ 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." + 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" 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) => { diff --git a/static/source/content_panel.ts b/static/source/content_panel.ts index 80396c99..785c131f 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,29 @@ 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); + } + } + + /** 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()) { @@ -257,6 +285,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 +293,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 +349,7 @@ export class ContentPanel { const dock = new DockPanel({ addButtonEnabled: true, + renderer: badgeDockRenderer, }); const area = { main: deserializeArea(dehydrated.main) }; if (area) { @@ -329,6 +359,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..e08a40fc 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,25 @@ 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); + } + + /** 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) { 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..bfebb1e0 --- /dev/null +++ b/static/source/content_panels/badge_tab_renderer.ts @@ -0,0 +1,127 @@ +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 }; 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 }; diff --git a/static/styles/dock_tab_attention.css b/static/styles/dock_tab_attention.css new file mode 100644 index 00000000..0a8879c7 --- /dev/null +++ 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; + } +} diff --git a/static/styles/file_tracking_detail.css b/static/styles/file_tracking_detail.css new file mode 100644 index 00000000..cfa5843f --- /dev/null +++ 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; +} diff --git a/static/styles/file_tracking_outliner.css b/static/styles/file_tracking_outliner.css new file mode 100644 index 00000000..ecb55247 --- /dev/null +++ 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; + } +} diff --git a/static/styles/file_tracking_panel.css b/static/styles/file_tracking_panel.css index 61d0d57e..0c06f339 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 1 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 +} diff --git a/static/styles/file_tracking_tabs.css b/static/styles/file_tracking_tabs.css new file mode 100644 index 00000000..9237a42b --- /dev/null +++ b/static/styles/file_tracking_tabs.css @@ -0,0 +1,248 @@ +.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, + &[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; + } + + & .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-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); +} + +.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 15rem; + min-width: 0; +} + +.file-tracking-item-row-file { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-tracking-item-row-detail { + color: var(--color-secondary); + font-size: 0.75rem; + overflow-wrap: anywhere; +} + +.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; + + &[hidden] { + display: none; + } +} 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 { 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; 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 }