From cbaf58a3313dd286d72eb9d15f7420e5cac21cba Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 21:06:21 +0200 Subject: [PATCH 01/11] Made bug issue template less intimidating. --- .github/ISSUE_TEMPLATE/bug_report.md | 31 ++++++++++------------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 481cd5c..7b33638 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,46 +6,37 @@ labels: bug assignees: '' --- -**Please do not report security vulnerabilities here.** See [SECURITY.md](../../SECURITY.md) for the private reporting process. +> [!WARNING] +> **Please do not report security vulnerabilities here.** See [SECURITY.md](../../SECURITY.md) for the private reporting process. + +> [!INFO] +> Don't worry about filling out every field, anything you can provide helps. Only the description really matters; the rest is optional. I care to know about issues, even if you don't write an essay about the problem. ## Describe the bug A clear, concise description of what's going wrong. -## Steps to reproduce +## Steps to reproduce (optional) 1. 2. 3. -## Expected behavior +## Expected behavior (optional) What you expected to happen instead. -## Actual behavior -What actually happened. Include exact error messages if any. - -## Screenshots or recordings +## Screenshots or recordings (optional) If applicable, attach screenshots or a short screen recording. Drag and drop works. -## Environment +## Environment (optional) - **nui-sftp version:** (e.g. 0.4.2 — see `nui-sftp --version` or About dialog) - **Install source:** (e.g. AUR, built from source, AppImage, release binary) - **OS and version:** (e.g. Arch Linux, Ubuntu 24.04, Windows 11) -- **Desktop environment / WM:** (e.g. KDE Plasma 6.1 on Wayland, GNOME 46 on X11) — Linux only -- **Architecture:** (e.g. x86_64, aarch64) -## Remote server (if relevant) -- **SSH server:** (e.g. OpenSSH 9.6, Dropbear, proprietary appliance) -- **Authentication method:** (password, public key, agent, certificate) -- **Connection type:** (direct, via ProxyJump, through a tunnel) - -## Logs +## Logs (optional) Logs can be found at: - **Linux:** `~/.local/state/nui-sftp/logs/` - **Windows:** `C:\Users\\Documents\nui-sftp\logs\` -Please paste the relevant log output below. **Redact hostnames, usernames, IPs, key fingerprints, and anything else sensitive** before sharing. +Please paste the relevant log output below. **Redact hostnames, usernames, IPs, key fingerprints, and anything else sensitive** before sharing.
- -## Additional context -Anything else worth knowing — frequency of the bug, recent changes to your setup, related issues, workarounds you've tried. From a19c61dcc181c5d59a30494da191cd935314bc1b Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 21:41:30 +0200 Subject: [PATCH 02/11] Fixed upload progress. --- .../backend/sftp/bulk_download_operation.cpp | 11 ++--------- .../backend/sftp/bulk_upload_operation.cpp | 17 +++++++---------- .../displayed_bulk_operation.hpp | 11 +++++++++++ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/backend/source/backend/sftp/bulk_download_operation.cpp b/backend/source/backend/sftp/bulk_download_operation.cpp index 81f9307..595f0ce 100644 --- a/backend/source/backend/sftp/bulk_download_operation.cpp +++ b/backend/source/backend/sftp/bulk_download_operation.cpp @@ -120,14 +120,7 @@ std::expected B if (!prescannedPathOverride_.empty()) { options_.overallProgressCallback( - options_.localPath, - currentIndex_, - entries_.size(), - 0, - 0, - currentBytes_, - totalBytes_, - bulkBytesPerSecond_ + options_.localPath, currentIndex_, entries_.size(), 0, 0, currentBytes_, totalBytes_, 0 ); } Log::info("BulkDownloadOperation: Bulk download completed."); @@ -370,7 +363,7 @@ void BulkDownloadOperation::completeCurrentDownload() if (!prescannedPathOverride_.empty() && currentIndex_ == entries_.size()) { options_.overallProgressCallback( - options_.localPath, currentIndex_, entries_.size(), 0, 0, currentBytes_, totalBytes_, bulkBytesPerSecond_ + options_.localPath, currentIndex_, entries_.size(), 0, 0, currentBytes_, totalBytes_, 0 ); } } diff --git a/backend/source/backend/sftp/bulk_upload_operation.cpp b/backend/source/backend/sftp/bulk_upload_operation.cpp index cbc6fb9..3690fa7 100644 --- a/backend/source/backend/sftp/bulk_upload_operation.cpp +++ b/backend/source/backend/sftp/bulk_upload_operation.cpp @@ -113,14 +113,7 @@ std::expected BulkU if (!prescannedPathOverride_.empty()) { options_.overallProgressCallback( - options_.remotePath, - currentIndex_, - entries_.size(), - 0, - 0, - currentBytes_, - totalBytes_, - bulkBytesPerSecond_ + options_.remotePath, currentIndex_, entries_.size(), 0, 0, currentBytes_, totalBytes_, 0 ); } Log::info("BulkUploadOperation: Bulk upload completed."); @@ -368,13 +361,17 @@ std::vector> BulkUp void BulkUploadOperation::completeCurrentUpload() { - currentBytes_ += currentUpload_->totalSize(); + // Advance by the entry's reported size, not UploadOperation::totalSize() + // (the actual on-disk size from tellg). totalBytes_ is summed from the same + // reported sizes, so accumulating the reported size keeps currentBytes_ + // converging exactly to totalBytes_, matching BulkDownloadOperation. + currentBytes_ += entries_[currentIndex_].size; currentUpload_.reset(); ++currentIndex_; if (!prescannedPathOverride_.empty() && currentIndex_ == entries_.size()) { options_.overallProgressCallback( - options_.remotePath, currentIndex_, entries_.size(), 0, 0, currentBytes_, totalBytes_, bulkBytesPerSecond_ + options_.remotePath, currentIndex_, entries_.size(), 0, 0, currentBytes_, totalBytes_, 0 ); } } 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 92aac57..24866e2 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 @@ -153,6 +153,17 @@ struct DisplayedBulkOperation : public OperationCard OperationCard::state(newState); if (isCompletedState()) { + // Pin both bars to their max and zero the speed. A dropped or late + // terminal progress tick can otherwise leave the bars short of 100% + // and the speed frozen at its last sample. Mirrors the count-pin in + // body(). + if (newState == SharedData::OperationState::Completed || + newState == SharedData::OperationState::PartialSuccess) + { + bytesPerSecond = 0; + totalProgressBar_.setProgress(totalProgressBar_.max()); + fileProgressBar_.setProgress(fileProgressBar_.max()); + } fileProgressBar_.setZeroAsComplete(); totalProgressBar_.setZeroAsComplete(); } From cc97c85b2e634e3784ebc8c501cc0cdffbc7f780 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 21:57:45 +0200 Subject: [PATCH 03/11] Made progress reports more frequent for large buffers. --- ssh/source/ssh/file_stream.cpp | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/ssh/source/ssh/file_stream.cpp b/ssh/source/ssh/file_stream.cpp index 952879e..c4c9e86 100644 --- a/ssh/source/ssh/file_stream.cpp +++ b/ssh/source/ssh/file_stream.cpp @@ -1,12 +1,40 @@ #include #include +#include #include #include #include namespace SecureShell { + namespace + { + /** @brief Number of buffer-sized transfers to run per strand turn. + * + * Batching amortizes the strand task overhead, but each turn blocks + * progress polling, so the bytes moved per turn are capped: servers + * that negotiate small buffers get more cycles, large ones get fewer. + * Also bounded by what is left so we don't overshoot near EOF. + * + * @param bufferSize Per-transfer chunk size (server-clamped). + * @param remainingBytes Bytes still to transfer. + */ + IFileStream::SignedSizeType transferCyclesPerTurn( + IFileStream::SignedSizeType bufferSize, + IFileStream::SignedSizeType remainingBytes + ) + { + using SignedSizeType = IFileStream::SignedSizeType; + constexpr SignedSizeType targetBytesPerTurn = 256 * 1024; + if (bufferSize <= 0) + return SignedSizeType{1}; + const auto byTarget = std::max(SignedSizeType{1}, targetBytesPerTurn / bufferSize); + const auto byRemaining = (remainingBytes / bufferSize) + SignedSizeType{1}; + return std::min(byTarget, byRemaining); + } + } + #define VERIFY_FILE_STREAM() \ if (!file_) \ return std::unexpected(SftpError{.message = "File is null", .wrapperError = WrapperErrors::FileNull}) @@ -424,8 +452,7 @@ namespace SecureShell } auto remainingRead = totalFileSize - context->bytesTransferred_.load(); - const auto readCycles = - std::min(SignedSizeType{10}, (remainingRead / bufferSize) + SignedSizeType{1}); + const auto readCycles = transferCyclesPerTurn(bufferSize, remainingRead); for (SignedSizeType i = 0; i != readCycles; ++i) { @@ -509,8 +536,7 @@ namespace SecureShell } auto remainingWrite = totalFileSize - context->bytesTransferred_.load(); - const auto writeCycles = - std::min(SignedSizeType{10}, (remainingWrite / bufferSize) + SignedSizeType{1}); + const auto writeCycles = transferCyclesPerTurn(bufferSize, remainingWrite); for (SignedSizeType i = 0; i != writeCycles; ++i) { From a99818511bbe3ce701abf28466f2c9ff84daca6f Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 22:16:45 +0200 Subject: [PATCH 04/11] Fixed missing refreshes. --- backend/source/backend/sftp/operation_queue.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/source/backend/sftp/operation_queue.cpp b/backend/source/backend/sftp/operation_queue.cpp index 9149323..08b8b41 100644 --- a/backend/source/backend/sftp/operation_queue.cpp +++ b/backend/source/backend/sftp/operation_queue.cpp @@ -1230,7 +1230,11 @@ std::size_t OperationQueue::addBulkDownloadOperation( entry.src, request.allowOverwrite, /*isBigFile*/ false, - /*insertRefresh*/ false, + // Propagate the request's flag so manual folder downloads refresh + // the local panel. Sync passes false to avoid refresh spam; a + // manual bulk download passes true and expects one refresh per + // top-level directory on completion. + /*insertRefresh*/ request.insertRefresh, /*createMissingDirectories*/ true, request.mode ); @@ -1506,7 +1510,11 @@ std::size_t OperationQueue::addBulkUploadOperation( entry.dst, request.allowOverwrite, /*isBigFile*/ false, - /*insertRefresh*/ false, + // Propagate the request's flag so manual folder uploads refresh + // the remote panel. Sync passes false to avoid refresh spam; a + // manual bulk upload passes true and expects one refresh per + // top-level directory on completion. + /*insertRefresh*/ request.insertRefresh, /*createMissingDirectories*/ true, request.mode ); From de34936c33557e6148d16087d7470e813aaa9b57 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 22:19:47 +0200 Subject: [PATCH 05/11] Fixed formatting and removed some logs. --- .../file_explorer/local_side_model.cpp | 150 +++++++++------- .../file_explorer/remote_side_model.cpp | 161 ++++++++++-------- 2 files changed, 175 insertions(+), 136 deletions(-) diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index 6b51fc7..d36999a 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -528,10 +528,6 @@ void LocalSideModel::onTransfer( } Log::info("Upload items requested: {}", items.size()); - for (const auto& item : items) - { - Log::debug("Item: {}", item.path.generic_string()); - } auto destinationDir = remoteModel_->currentPath().value(); if (subDir) @@ -604,14 +600,13 @@ void LocalSideModel::onTransfer( fileEngine_->existsBatchRemote( remoteDestPaths, - [this, uploadItems = std::move(uploadItems)](std::vector exists, std::string const& info) mutable + [this, + uploadItems = std::move(uploadItems)](std::vector exists, std::string const& info) mutable { auto existsResults = std::make_shared>(); if (exists.empty() && !uploadItems.empty()) { - Log::warn( - "sftp::existsBatch failed: {}; assuming nothing exists yet", info - ); + Log::warn("sftp::existsBatch failed: {}; assuming nothing exists yet", info); existsResults->assign(uploadItems.size(), false); } else @@ -729,7 +724,8 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) "RpcFilesystem::properties", [this, fallback = std::move(fallback)](Nui::val val) mutable { - const auto useFallback = [this, &fallback]() { + const auto useFallback = [this, &fallback]() + { filePropertyDialog_->open(fallback); }; @@ -745,8 +741,10 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) return; } - const auto extractFull = [](Nui::val const& source) -> SharedData::DirectoryEntry { - const auto pickU64 = [&source](char const* key) -> std::uint64_t { + const auto extractFull = [](Nui::val const& source) -> SharedData::DirectoryEntry + { + const auto pickU64 = [&source](char const* key) -> std::uint64_t + { if (!source.hasOwnProperty(key)) return 0; const auto field = source[key]; @@ -754,7 +752,8 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) return 0; return field.template as(); }; - const auto pickU32 = [&source](char const* key) -> std::uint32_t { + const auto pickU32 = [&source](char const* key) -> std::uint32_t + { if (!source.hasOwnProperty(key)) return 0; const auto field = source[key]; @@ -762,7 +761,8 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) return 0; return field.template as(); }; - const auto pickString = [&source](char const* key) -> std::string { + const auto pickString = [&source](char const* key) -> std::string + { if (!source.hasOwnProperty(key)) return {}; const auto field = source[key]; @@ -898,8 +898,10 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) return; } - const auto extractEntry = [](Nui::val const& source) -> SharedData::DirectoryEntry { - const auto pickU64 = [&source](char const* key) -> std::uint64_t { + const auto extractEntry = [](Nui::val const& source) -> SharedData::DirectoryEntry + { + const auto pickU64 = [&source](char const* key) -> std::uint64_t + { if (!source.hasOwnProperty(key)) return 0; const auto field = source[key]; @@ -907,7 +909,8 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) return 0; return field.template as(); }; - const auto pickU32 = [&source](char const* key) -> std::uint32_t { + const auto pickU32 = [&source](char const* key) -> std::uint32_t + { if (!source.hasOwnProperty(key)) return 0; const auto field = source[key]; @@ -915,7 +918,8 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) return 0; return field.template as(); }; - const auto pickString = [&source](char const* key) -> std::string { + const auto pickString = [&source](char const* key) -> std::string + { if (!source.hasOwnProperty(key)) return {}; const auto field = source[key]; @@ -964,8 +968,7 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) const auto target = file["resolvedTarget"]; if (!target.isNull() && !target.isUndefined()) { - entry.resolvedTarget = - std::make_shared(extractEntry(target)); + entry.resolvedTarget = std::make_shared(extractEntry(target)); } } directoryEntries.push_back(std::move(entry)); @@ -993,26 +996,28 @@ void LocalSideModel::uploadItemsConfirmed( if (!accepted) accepted = std::make_shared>(); - auto pushEntry = []( - std::vector& bucket, - NuiFileExplorer::Item const& remoteItem, - NuiFileExplorer::Item const& localItem - ) { - bucket.push_back(SharedData::BulkAddEntry{ - // For uploads, src is local and dst is remote — opposite of - // download (the bulk RPC is symmetric on field naming). - .src = !localItem.fullPath.empty() ? localItem.fullPath : localItem.path, - .dst = !remoteItem.fullPath.empty() ? remoteItem.fullPath : remoteItem.path, - .sizeBytes = localItem.size, - .isDirectory = localItem.isDirectory(), - }); + auto pushEntry = [](std::vector& bucket, + NuiFileExplorer::Item const& remoteItem, + NuiFileExplorer::Item const& localItem) + { + bucket.push_back( + SharedData::BulkAddEntry{ + // For uploads, src is local and dst is remote — opposite of + // download (the bulk RPC is symmetric on field naming). + .src = !localItem.fullPath.empty() ? localItem.fullPath : localItem.path, + .dst = !remoteItem.fullPath.empty() ? remoteItem.fullPath : remoteItem.path, + .sizeBytes = localItem.size, + .isDirectory = localItem.isDirectory(), + } + ); }; // Every entry in `accepted` is either a non-existing destination or an // item the user explicitly approved for overwrite (Yes / All); the "No" // and "None" branches skip the push. So allowOverwrite=true at flush // time is semantically correct regardless of the overwriteAlways flag. - auto flushAccepted = [this, &accepted]() { + auto flushAccepted = [this, &accepted]() + { if (accepted->empty()) return; // Single-file fast path: a one-entry flush skips the bulk machinery @@ -1048,7 +1053,8 @@ void LocalSideModel::uploadItemsConfirmed( /*insertRefresh*/ true, SharedData::OperationMode::Queued, /*onEachComplete*/ {}, - [this](bool success, std::string const& info) { + [this](bool success, std::string const& info) + { if (!success) { Log::error("Bulk upload failed: {}", info); @@ -1126,19 +1132,24 @@ void LocalSideModel::uploadItemsConfirmed( { pushEntry(*accepted, uploadItems[index].first, uploadItems[index].second); uploadItemsConfirmed( - std::move(uploadItems), std::move(existsResults), index + 1, - overwriteNever, overwriteAlways, std::move(accepted) + std::move(uploadItems), + std::move(existsResults), + index + 1, + overwriteNever, + overwriteAlways, + std::move(accepted) ); } else if (button && *button == ConfirmDialog::Button::No) { - Log::info( - "Skipping upload of existing file: {}", - uploadItems[index].second.path.generic_string() - ); + Log::info("Skipping upload of existing file: {}", uploadItems[index].second.path.generic_string()); uploadItemsConfirmed( - std::move(uploadItems), std::move(existsResults), index + 1, - overwriteNever, overwriteAlways, std::move(accepted) + std::move(uploadItems), + std::move(existsResults), + index + 1, + overwriteNever, + overwriteAlways, + std::move(accepted) ); } else if (button && *button == ConfirmDialog::Button::All) @@ -1146,24 +1157,36 @@ void LocalSideModel::uploadItemsConfirmed( Log::info("Overwriting all existing files from now on."); pushEntry(*accepted, uploadItems[index].first, uploadItems[index].second); uploadItemsConfirmed( - std::move(uploadItems), std::move(existsResults), index + 1, - overwriteNever, /*overwriteAlways*/ true, std::move(accepted) + std::move(uploadItems), + std::move(existsResults), + index + 1, + overwriteNever, + /*overwriteAlways*/ true, + std::move(accepted) ); } else if (button && *button == ConfirmDialog::Button::None) { Log::info("Skipping all existing files from now on."); uploadItemsConfirmed( - std::move(uploadItems), std::move(existsResults), index + 1, - /*overwriteNever*/ true, overwriteAlways, std::move(accepted) + std::move(uploadItems), + std::move(existsResults), + index + 1, + /*overwriteNever*/ true, + overwriteAlways, + std::move(accepted) ); } else { const auto terminalIndex = uploadItems.size(); uploadItemsConfirmed( - std::move(uploadItems), std::move(existsResults), terminalIndex, - overwriteNever, overwriteAlways, std::move(accepted) + std::move(uploadItems), + std::move(existsResults), + terminalIndex, + overwriteNever, + overwriteAlways, + std::move(accepted) ); } }} @@ -1421,11 +1444,16 @@ namespace { switch (codec) { - case ArchiveCodec::None: return 1; - case ArchiveCodec::Gzip: return 2; - case ArchiveCodec::Bzip2: return 3; - case ArchiveCodec::Zstd: return 4; - case ArchiveCodec::Xz: return 5; + case ArchiveCodec::None: + return 1; + case ArchiveCodec::Gzip: + return 2; + case ArchiveCodec::Bzip2: + return 3; + case ArchiveCodec::Zstd: + return 4; + case ArchiveCodec::Xz: + return 5; } return 2; } @@ -1462,18 +1490,15 @@ void LocalSideModel::onTransferAsArchive(std::vector cons if (localPaths.empty()) return; - const auto defaultStem = items.size() == 1 - ? items.front().path.filename().generic_string() - : std::string{"archive"}; + const auto defaultStem = + items.size() == 1 ? items.front().path.filename().generic_string() : std::string{"archive"}; archiveTransferDialog_->open({ .headerText = "Upload as Archive", .initialFileStem = defaultStem, .initialCodec = ArchiveCodec::Gzip, .initialCompressionLevel = 5, - .onConfirm = [this, paths = std::move(localPaths)]( - std::optional const& result - ) mutable + .onConfirm = [this, paths = std::move(localPaths)](std::optional const& result) mutable { if (!result) { @@ -1483,8 +1508,7 @@ void LocalSideModel::onTransferAsArchive(std::vector cons if (!remoteModel_) return; - const std::string filename = - result->fileStem + ".tar" + archiveCodecExtension(result->codec); + const std::string filename = result->fileStem + ".tar" + archiveCodecExtension(result->codec); const auto remotePath = remoteModel_->currentPath().value() / filename; operationQueue_->enqueueArchiveUpload( @@ -1508,9 +1532,7 @@ void LocalSideModel::onTransferAsArchive(std::vector cons return; } Log::info( - "Archive upload queued as {} (destination {})", - opId->value(), - remotePath.generic_string() + "Archive upload queued as {} (destination {})", opId->value(), remotePath.generic_string() ); } ); diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 2fd1d09..764a4e4 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -507,21 +507,25 @@ void RemoteSideModel::enqueueDeletes( entries.reserve(filesAndEmptyDirs.size() + nonEmpties.size()); for (auto const& path : filesAndEmptyDirs) { - entries.push_back(SharedData::BulkAddEntry{ - .src = path, - .dst = {}, - .sizeBytes = 0, - .isDirectory = false, - }); + entries.push_back( + SharedData::BulkAddEntry{ + .src = path, + .dst = {}, + .sizeBytes = 0, + .isDirectory = false, + } + ); } for (auto const& path : nonEmpties) { - entries.push_back(SharedData::BulkAddEntry{ - .src = path, - .dst = {}, - .sizeBytes = 0, - .isDirectory = true, - }); + entries.push_back( + SharedData::BulkAddEntry{ + .src = path, + .dst = {}, + .sizeBytes = 0, + .isDirectory = true, + } + ); } if (entries.empty()) @@ -532,7 +536,8 @@ void RemoteSideModel::enqueueDeletes( /*insertRefresh*/ true, SharedData::OperationMode::Queued, /*onBulkComplete*/ {}, - [this](bool success, std::string const& info) { + [this](bool success, std::string const& info) + { if (!success) { Log::error("Bulk delete failed: {}", info); @@ -556,10 +561,6 @@ void RemoteSideModel::onDelete(std::vector const& items) using namespace std::string_literals; Log::info("Delete items requested: {}", items.size()); - for (const auto& item : items) - { - Log::info("Item: {}", item.path.generic_string()); - } if (items.empty()) { @@ -690,26 +691,28 @@ void RemoteSideModel::downloadItemsConfirmed( if (!accepted) accepted = std::make_shared>(); - auto pushEntry = []( - std::vector& bucket, - NuiFileExplorer::Item const& remoteItem, - NuiFileExplorer::Item const& localItem - ) { - bucket.push_back(SharedData::BulkAddEntry{ - .src = !remoteItem.fullPath.empty() ? remoteItem.fullPath : remoteItem.path, - .dst = !localItem.fullPath.empty() ? localItem.fullPath : localItem.path, - .sizeBytes = remoteItem.size, - .isDirectory = remoteItem.isDirectory(), - .mtime = remoteItem.mtime, - .mtimeNsec = remoteItem.mtimeNsec, - }); + auto pushEntry = [](std::vector& bucket, + NuiFileExplorer::Item const& remoteItem, + NuiFileExplorer::Item const& localItem) + { + bucket.push_back( + SharedData::BulkAddEntry{ + .src = !remoteItem.fullPath.empty() ? remoteItem.fullPath : remoteItem.path, + .dst = !localItem.fullPath.empty() ? localItem.fullPath : localItem.path, + .sizeBytes = remoteItem.size, + .isDirectory = remoteItem.isDirectory(), + .mtime = remoteItem.mtime, + .mtimeNsec = remoteItem.mtimeNsec, + } + ); }; // Every entry in `accepted` is either a non-existing destination or an // item the user explicitly approved for overwrite (Yes / All); the "No" // and "None" branches skip the push. So allowOverwrite=true at flush // time is semantically correct regardless of the overwriteAlways flag. - auto flushAccepted = [this, &accepted]() { + auto flushAccepted = [this, &accepted]() + { if (accepted->empty()) return; // Single-file fast path: a one-entry flush skips the bulk machinery @@ -747,7 +750,8 @@ void RemoteSideModel::downloadItemsConfirmed( /*insertRefresh*/ true, SharedData::OperationMode::Queued, /*onEachComplete*/ {}, - [this](bool success, std::string const& info) { + [this](bool success, std::string const& info) + { if (!success) { Log::error("Bulk download failed: {}", info); @@ -844,19 +848,26 @@ void RemoteSideModel::downloadItemsConfirmed( { pushEntry(*accepted, downloadItems[index].first, downloadItems[index].second); downloadItemsConfirmed( - std::move(downloadItems), std::move(existsResults), index + 1, - overwriteNever, overwriteAlways, std::move(accepted) + std::move(downloadItems), + std::move(existsResults), + index + 1, + overwriteNever, + overwriteAlways, + std::move(accepted) ); } else if (button && button == ConfirmDialog::Button::No) { Log::info( - "Skipping download of existing file: {}", - downloadItems[index].second.path.generic_string() + "Skipping download of existing file: {}", downloadItems[index].second.path.generic_string() ); downloadItemsConfirmed( - std::move(downloadItems), std::move(existsResults), index + 1, - overwriteNever, overwriteAlways, std::move(accepted) + std::move(downloadItems), + std::move(existsResults), + index + 1, + overwriteNever, + overwriteAlways, + std::move(accepted) ); } else if (button && button == ConfirmDialog::Button::All) @@ -864,16 +875,24 @@ void RemoteSideModel::downloadItemsConfirmed( Log::info("Overwriting all existing files from now on."); pushEntry(*accepted, downloadItems[index].first, downloadItems[index].second); downloadItemsConfirmed( - std::move(downloadItems), std::move(existsResults), index + 1, - overwriteNever, /*overwriteAlways*/ true, std::move(accepted) + std::move(downloadItems), + std::move(existsResults), + index + 1, + overwriteNever, + /*overwriteAlways*/ true, + std::move(accepted) ); } else if (button && button == ConfirmDialog::Button::None) { Log::info("Skipping all existing files from now on."); downloadItemsConfirmed( - std::move(downloadItems), std::move(existsResults), index + 1, - /*overwriteNever*/ true, overwriteAlways, std::move(accepted) + std::move(downloadItems), + std::move(existsResults), + index + 1, + /*overwriteNever*/ true, + overwriteAlways, + std::move(accepted) ); } else @@ -882,8 +901,12 @@ void RemoteSideModel::downloadItemsConfirmed( // accepted so far and stop iterating. const auto terminalIndex = downloadItems.size(); downloadItemsConfirmed( - std::move(downloadItems), std::move(existsResults), terminalIndex, - overwriteNever, overwriteAlways, std::move(accepted) + std::move(downloadItems), + std::move(existsResults), + terminalIndex, + overwriteNever, + overwriteAlways, + std::move(accepted) ); } }} @@ -911,10 +934,6 @@ void RemoteSideModel::onTransfer( CHECK_COMPLETE(); Log::info("Download items requested: {}", items.size()); - for (const auto& item : items) - { - Log::debug("Item: {}", item.path.generic_string()); - } if (items.empty()) { @@ -1003,9 +1022,7 @@ void RemoteSideModel::onTransfer( if (!response.hasOwnProperty("success") || !response["success"].as() || !response.hasOwnProperty("exists")) { - Log::warn( - "RpcFilesystem::existsBatch failed; assuming nothing exists yet" - ); + Log::warn("RpcFilesystem::existsBatch failed; assuming nothing exists yet"); existsResults->assign(downloadItems.size(), false); } else @@ -1041,11 +1058,16 @@ namespace { switch (codec) { - case ArchiveCodec::None: return 1; - case ArchiveCodec::Gzip: return 2; - case ArchiveCodec::Bzip2: return 3; - case ArchiveCodec::Zstd: return 4; - case ArchiveCodec::Xz: return 5; + case ArchiveCodec::None: + return 1; + case ArchiveCodec::Gzip: + return 2; + case ArchiveCodec::Bzip2: + return 3; + case ArchiveCodec::Zstd: + return 4; + case ArchiveCodec::Xz: + return 5; } return 2; } @@ -1092,18 +1114,16 @@ void RemoteSideModel::onTransferAsArchive(std::vector con if (rootEntries.empty()) return; - const auto defaultStem = items.size() == 1 - ? items.front().path.filename().generic_string() - : std::string{"archive"}; + const auto defaultStem = + items.size() == 1 ? items.front().path.filename().generic_string() : std::string{"archive"}; archiveTransferDialog_->open({ .headerText = "Download as Archive", .initialFileStem = defaultStem, .initialCodec = ArchiveCodec::Gzip, .initialCompressionLevel = 5, - .onConfirm = [this, entries = std::move(rootEntries)]( - std::optional const& result - ) mutable + .onConfirm = + [this, entries = std::move(rootEntries)](std::optional const& result) mutable { if (!result) { @@ -1113,8 +1133,7 @@ void RemoteSideModel::onTransferAsArchive(std::vector con if (!localModel_) return; - const std::string filename = - result->fileStem + ".tar" + archiveCodecExtension(result->codec); + const std::string filename = result->fileStem + ".tar" + archiveCodecExtension(result->codec); const auto localPath = localModel_->currentPath().value() / filename; operationQueue_->enqueueArchiveDownload( @@ -1138,9 +1157,7 @@ void RemoteSideModel::onTransferAsArchive(std::vector con return; } Log::info( - "Archive download queued as {} (destination {})", - opId->value(), - localPath.generic_string() + "Archive download queued as {} (destination {})", opId->value(), localPath.generic_string() ); } ); @@ -1473,13 +1490,13 @@ void RemoteSideModel::requestDefaultPlaces(std::function> defaults = { - {"Home", home}, - {"Desktop", home + "/Desktop"}, + {"Home", home}, + {"Desktop", home + "/Desktop"}, {"Downloads", home + "/Downloads"}, {"Documents", home + "/Documents"}, - {"Pictures", home + "/Pictures"}, - {"Videos", home + "/Videos"}, - {"Music", home + "/Music"}, + {"Pictures", home + "/Pictures"}, + {"Videos", home + "/Videos"}, + {"Music", home + "/Music"}, }; std::vector entries; From d661293fe3c412fe388bed5511984761bc84cad3 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 22:36:05 +0200 Subject: [PATCH 06/11] Added limit to confirm dialog items. --- .../source/frontend/dialog/confirm_dialog.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/source/frontend/dialog/confirm_dialog.cpp b/frontend/source/frontend/dialog/confirm_dialog.cpp index 0e00edd..cb2d17c 100644 --- a/frontend/source/frontend/dialog/confirm_dialog.cpp +++ b/frontend/source/frontend/dialog/confirm_dialog.cpp @@ -16,6 +16,10 @@ #include #include +#include + +#include + using namespace std::string_literals; namespace Snc = ScriptNuiComponents; @@ -78,10 +82,17 @@ void ConfirmDialog::open(OpenOptions const& options) impl_->listItemsPresent = !options.listItems.empty(); impl_->table.clear(); - for (const auto& item : options.listItems) - { - impl_->table.addRow({item.text}); - } + // Each row is a nested reactive range plus DOM nodes, so a multi-thousand + // item drop (bulk transfer confirmation) would build that many rows on the + // WASM thread and stall. The list is purely informational; the action still + // runs on the full set, so cap the rendered rows and summarize the rest. + constexpr std::size_t maxListedRows = 200; + const auto totalRows = options.listItems.size(); + const auto shownRows = std::min(totalRows, maxListedRows); + for (std::size_t idx = 0; idx < shownRows; ++idx) + impl_->table.addRow({options.listItems[idx].text}); + if (totalRows > shownRows) + impl_->table.addRow({fmt::format("... and {} more", totalRows - shownRows)}); impl_->text = options.text; impl_->dialog->open( {.styleVariant = options.styleVariant, From 651fc2f495905ab36060b2efadf6219d0c993978 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 22:52:40 +0200 Subject: [PATCH 07/11] Turned any delete into queued delete to avoid timeouts. --- .../session_components/operation_queue.cpp | 5 +- .../source/frontend/terminal/file_engine.cpp | 80 +++++++------------ 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/frontend/source/frontend/session_components/operation_queue.cpp b/frontend/source/frontend/session_components/operation_queue.cpp index 71f5212..d06923d 100644 --- a/frontend/source/frontend/session_components/operation_queue.cpp +++ b/frontend/source/frontend/session_components/operation_queue.cpp @@ -985,7 +985,10 @@ void OperationQueue::onDeleteProgress(SharedData::BulkDeleteProgress const& prog Log::error("Received delete progress for unknown operation id: {}", progress.operationId.value()); return; } - if (operation->type() != SharedData::OperationType::Delete) + // Both Delete (per-directory recursive) and BulkDelete (the file/empty-dir + // aggregate) cards render via DisplayedDeleteOperation and emit onDeleteProgress. + if (operation->type() != SharedData::OperationType::Delete && + operation->type() != SharedData::OperationType::BulkDelete) { Log::error("Received delete progress for operation id: {} which is not a delete", progress.operationId.value()); return; diff --git a/frontend/source/frontend/terminal/file_engine.cpp b/frontend/source/frontend/terminal/file_engine.cpp index 92a1733..3446c9a 100644 --- a/frontend/source/frontend/terminal/file_engine.cpp +++ b/frontend/source/frontend/terminal/file_engine.cpp @@ -199,13 +199,19 @@ void FileEngine::openSyncSession( ) { Log::info( - "Requesting openSyncSession: local='{}' remote='{}'", - localPath.generic_string(), - remotePath.generic_string() + "Requesting openSyncSession: local='{}' remote='{}'", localPath.generic_string(), remotePath.generic_string() ); lazyOpen( - [this, localPath, remotePath, syncSessionId, remoteScanId, localScanId, respectIgnoreFiles, recursive, - ignoreHidden, onComplete = std::move(onComplete)](auto const& channelId, std::string const& info) + [this, + localPath, + remotePath, + syncSessionId, + remoteScanId, + localScanId, + respectIgnoreFiles, + recursive, + ignoreHidden, + onComplete = std::move(onComplete)](auto const& channelId, std::string const& info) { if (!channelId) { @@ -483,18 +489,13 @@ void FileEngine::addArchiveDownload( entriesJson.push_back(entry); Nui::RpcClient::callWithBackChannel( - fmt::format( - "Session::{}::sftp::addArchiveDownload", impl_->engine->sshSessionId().value() - ), + fmt::format("Session::{}::sftp::addArchiveDownload", impl_->engine->sshSessionId().value()), [onOperationCreated = std::move(onOperationCreated), operationId](Nui::val val) { Nui::WebApi::Console::log(val); if (val.hasOwnProperty("error")) { - Log::error( - "(Frontend) Failed to add archive download: {}", - val["error"].as() - ); + Log::error("(Frontend) Failed to add archive download: {}", val["error"].as()); onOperationCreated(std::nullopt, val["error"].as()); return; } @@ -556,18 +557,13 @@ void FileEngine::addArchiveUpload( localPathStrings.push_back(localPath.generic_string()); Nui::RpcClient::callWithBackChannel( - fmt::format( - "Session::{}::sftp::addArchiveUpload", impl_->engine->sshSessionId().value() - ), + fmt::format("Session::{}::sftp::addArchiveUpload", impl_->engine->sshSessionId().value()), [onOperationCreated = std::move(onOperationCreated), operationId](Nui::val val) { Nui::WebApi::Console::log(val); if (val.hasOwnProperty("error")) { - Log::error( - "(Frontend) Failed to add archive upload: {}", - val["error"].as() - ); + Log::error("(Frontend) Failed to add archive upload: {}", val["error"].as()); onOperationCreated(std::nullopt, val["error"].as()); return; } @@ -883,8 +879,7 @@ void FileEngine::remove( Nui::RpcClient::callWithBackChannel( fmt::format("Session::{}::sftp::preDeleteChecks", impl_->engine->sshSessionId().value()), - [this, - onComplete = std::move(onComplete), + [onComplete = std::move(onComplete), files = std::move(files), directories = std::move(directories), onNonEmptyDirectoriesFound = std::move(onNonEmptyDirectoriesFound)](Nui::val val) mutable @@ -906,36 +901,22 @@ void FileEngine::remove( std::vector nonEmpties; Nui::convertFromVal(val["nonEmptyDirectories"], nonEmpties); - if (nonEmpties.empty()) + std::vector filesAndEmptyDirs; + filesAndEmptyDirs.reserve(files.size() + (directories.size() - nonEmpties.size())); + for (const auto& file : files) + filesAndEmptyDirs.push_back(file.path); + for (const auto& dir : directories) { - std::vector transformedDirectories; - transformedDirectories.resize(directories.size()); - std::transform( - directories.begin(), - directories.end(), - transformedDirectories.begin(), - [](auto const& item) - { - return item.path; - } - ); - performDelete(std::move(files), std::move(transformedDirectories), std::move(onComplete)); + if (std::find(nonEmpties.begin(), nonEmpties.end(), dir.path) == nonEmpties.end()) + filesAndEmptyDirs.push_back(dir.path); } - else - { - std::vector filesAndEmptyDirs; - filesAndEmptyDirs.reserve(files.size() + (directories.size() - nonEmpties.size())); - for (const auto& file : files) - filesAndEmptyDirs.push_back(file.path); - for (const auto& dir : directories) - { - if (std::find(nonEmpties.begin(), nonEmpties.end(), dir.path) == nonEmpties.end()) - filesAndEmptyDirs.push_back(dir.path); - } - // Dont actually perform delete here immediately, this is something for the queue! - onNonEmptyDirectoriesFound(std::move(filesAndEmptyDirs), std::move(nonEmpties)); - } + // Always route through the queue (bulk delete), never a synchronous + // deleteFiles RPC: a large selection blows the single futureTimeout + // even though the server keeps deleting, surfacing a spurious + // "Failed to delete files: timeout". nonEmpties may be empty, in which + // case the caller enqueues the file/empty-dir batch immediately. + onNonEmptyDirectoriesFound(std::move(filesAndEmptyDirs), std::move(nonEmpties)); }, channelId.value().value(), transformedDirectories @@ -1179,8 +1160,7 @@ void FileEngine::existsBatchRemote( onComplete({}, val["error"].as()); return; } - if (!val.hasOwnProperty("success") || !val["success"].as() || - !val.hasOwnProperty("exists")) + if (!val.hasOwnProperty("success") || !val["success"].as() || !val.hasOwnProperty("exists")) { onComplete({}, "Malformed sftp::existsBatch response"); return; From 989f1b5e9e47973070d477fc4103d578b444979a Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 23:11:20 +0200 Subject: [PATCH 08/11] Improved delete operation text to be clearer. --- backend/source/backend/sftp/operation_queue.cpp | 5 +++++ .../operation_queue/displayed_delete_operation.hpp | 12 +++++++----- .../operation_queue/operation_card.hpp | 3 ++- .../frontend/session_components/operation_queue.cpp | 1 + 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/source/backend/sftp/operation_queue.cpp b/backend/source/backend/sftp/operation_queue.cpp index 08b8b41..49d35e1 100644 --- a/backend/source/backend/sftp/operation_queue.cpp +++ b/backend/source/backend/sftp/operation_queue.cpp @@ -1737,6 +1737,10 @@ std::size_t OperationQueue::addBulkDeleteOperation( if (!fileEntries.empty()) { const auto fileCount = fileEntries.size(); + // Parent of the batch, surfaced on the card so it can read "Deleting + // items in " before any per-file progress arrives. Captured before + // fileEntries is moved into the operation below. + const auto commonParent = fileEntries.front().path.parent_path(); // recursive=true so DeleteOperation walks the prefilled entries_ // (its non-recursive branch ignores entries_ and only deletes the // top-level remotePath). remotePath is left empty; not used in @@ -1778,6 +1782,7 @@ std::size_t OperationQueue::addBulkDeleteOperation( .mode = request.mode, .insertRefresh = request.insertRefresh, .totalBytes = static_cast(fileCount), + .remotePath = commonParent, } ); } 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 23b8032..c99574b 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 @@ -10,13 +10,14 @@ struct DisplayedDeleteOperation : public OperationCard DisplayedDeleteOperation( Ids::OperationId operationId, ConfirmDialog& confirmDialog, + SharedData::OperationType type, std::filesystem::path removePath, std::function doRemoveSelf, std::shared_ptr> doDeletionCountdown, std::function onCompleteAction ) : OperationCard{ - SharedData::OperationType::Delete, + type, confirmDialog, std::move(operationId), std::move(doRemoveSelf), @@ -104,10 +105,11 @@ struct DisplayedDeleteOperation : public OperationCard }( observe(currentFile), [this](){ - return fmt::format( - "Deleting: '{}'", - currentFile.value().empty() ? removePath_.generic_string() : currentFile.value() - ); + if (type_ == SharedData::OperationType::BulkDelete) + return fmt::format("Deleting selected items in {}", removePath_.generic_string()); + if (currentFile.value().empty()) + return fmt::format("Deleting: '{}'", removePath_.generic_string()); + return fmt::format("Deleting: '{}'", currentFile.value()); } ) ), diff --git a/frontend/include/frontend/session_components/operation_queue/operation_card.hpp b/frontend/include/frontend/session_components/operation_queue/operation_card.hpp index 767a6d9..34d765f 100644 --- a/frontend/include/frontend/session_components/operation_queue/operation_card.hpp +++ b/frontend/include/frontend/session_components/operation_queue/operation_card.hpp @@ -175,7 +175,8 @@ class OperationCard : public OperationCardInterface { return Ui5Icons::edit(); } - else if (type_ == SharedData::OperationType::Delete) + else if (type_ == SharedData::OperationType::Delete || + type_ == SharedData::OperationType::BulkDelete) { return Ui5Icons::delete_(); } diff --git a/frontend/source/frontend/session_components/operation_queue.cpp b/frontend/source/frontend/session_components/operation_queue.cpp index d06923d..c5ac81b 100644 --- a/frontend/source/frontend/session_components/operation_queue.cpp +++ b/frontend/source/frontend/session_components/operation_queue.cpp @@ -788,6 +788,7 @@ void OperationQueue::onOperationAdded(SharedData::OperationAdded const& added) return std::make_unique( added.operationId, *impl_->confirmDialog, + added.type, added.remotePath ? *added.remotePath : std::filesystem::path{}, [this](OperationCard const& operation) { From b11be8890eb6cfdcf11220663f1bbd5e92b14517 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 23:17:48 +0200 Subject: [PATCH 09/11] Made sort by name case insensitive. --- .../side/side_implementation.hpp | 9 +++++-- .../utility/algorithm/case_convert.hpp | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) 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 8f71133..e3b9c82 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 @@ -18,6 +18,7 @@ #include #include +#include #include #include @@ -155,7 +156,9 @@ namespace NuiFileExplorer sortByPredicate( [](auto const& lhs, auto const& rhs) { - return lhs.item.path.filename() < rhs.item.path.filename(); + return Utility::Algorithm::caseInsensitiveCompare( + lhs.item.path.filename().string(), + rhs.item.path.filename().string()) < 0; } ); } @@ -164,7 +167,9 @@ namespace NuiFileExplorer sortByPredicate( [](auto const& lhs, auto const& rhs) { - return lhs.item.path.filename() > rhs.item.path.filename(); + return Utility::Algorithm::caseInsensitiveCompare( + lhs.item.path.filename().string(), + rhs.item.path.filename().string()) > 0; } ); } diff --git a/utility/include/utility/algorithm/case_convert.hpp b/utility/include/utility/algorithm/case_convert.hpp index c57706a..72373fb 100644 --- a/utility/include/utility/algorithm/case_convert.hpp +++ b/utility/include/utility/algorithm/case_convert.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include namespace Utility::Algorithm @@ -59,4 +61,27 @@ namespace Utility::Algorithm }); return result; } + + /** + * @brief Case-insensitive lexicographic comparison of two strings. + * + * @param lhs The left-hand string. + * @param rhs The right-hand string. + * @return Negative if lhs orders before rhs, zero if equal, positive if lhs orders after + * rhs, all under case-insensitive ordering. + */ + inline int caseInsensitiveCompare(std::string_view lhs, std::string_view rhs) + { + const auto commonLength = std::min(lhs.size(), rhs.size()); + for (std::size_t index = 0; index < commonLength; ++index) + { + const auto leftChar = std::tolower(static_cast(lhs[index])); + const auto rightChar = std::tolower(static_cast(rhs[index])); + if (leftChar != rightChar) + return leftChar < rightChar ? -1 : 1; + } + if (lhs.size() == rhs.size()) + return 0; + return lhs.size() < rhs.size() ? -1 : 1; + } } \ No newline at end of file From 1819532e8fbb072798abefe971902e1e1a887fe1 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 23:48:34 +0200 Subject: [PATCH 10/11] Optimized upload exists check drastically. --- backend/source/backend/session.cpp | 104 +++++++++++++++++------------ 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/backend/source/backend/session.cpp b/backend/source/backend/session.cpp index dfeb220..c6e3566 100644 --- a/backend/source/backend/session.cpp +++ b/backend/source/backend/session.cpp @@ -9,6 +9,10 @@ #include +#include +#include +#include + using namespace std::chrono_literals; Session::Session( @@ -189,8 +193,7 @@ void Session::adoptBulkResumes(std::vector const& operationIds if (!sftp) { Log::warn( - "Session::adoptBulkResumes: no sftp channel open yet — deferring {} resume(s)", - operationIds.size() + "Session::adoptBulkResumes: no sftp channel open yet — deferring {} resume(s)", operationIds.size() ); return; } @@ -201,8 +204,7 @@ void Session::adoptBulkResumes(std::vector const& operationIds if (!entry) { Log::info( - "Session::adoptBulkResumes: no backup for operation '{}' (already evicted?)", - opId.value() + "Session::adoptBulkResumes: no backup for operation '{}' (already evicted?)", opId.value() ); continue; } @@ -806,8 +808,7 @@ void Session::registerRpcSftpAddArchiveDownloadOperation() std::vector entries; try { - entries = nlohmann::json::parse(entriesJson) - .get>(); + entries = nlohmann::json::parse(entriesJson).get>(); } catch (std::exception const& exc) { @@ -929,14 +930,10 @@ void Session::registerRpcSftpAddArchiveUploadOperation() remoteArchivePath, result.error().toString() ); - return reply.error( - "Failed to add archive-upload operation: " + result.error().toString() - ); + return reply.error("Failed to add archive-upload operation: " + result.error().toString()); } Log::info( - "Added archive-upload operation '{}' → '{}'", - newOperationIdString, - remoteArchivePath + "Added archive-upload operation '{}' → '{}'", newOperationIdString, remoteArchivePath ); self->resetQueueThrottle(); reply({{"success", true}}); @@ -967,9 +964,7 @@ void Session::registerRpcSftpAddBulkDownloadOperation() // Expect N per-entry ids + 1 dedicated aggregate-bulk-card id at the end. if (operationIdStrings.size() != request.entries.size() + 1) - return reply.error( - "addBulkDownload: operationIds and entries length mismatch" - ); + return reply.error("addBulkDownload: operationIds and entries length mismatch"); self->withSftpChannelDo( Ids::makeChannelId(channelIdString), @@ -985,17 +980,14 @@ void Session::registerRpcSftpAddBulkDownloadOperation() const auto enqueued = self->operationQueue_->addBulkDownloadOperation( *channel, request, - [&operationIdStrings](std::size_t idx) { + [&operationIdStrings](std::size_t idx) + { return Ids::makeOperationId(operationIdStrings[idx]); }, bulkCardId ); - Log::info( - "addBulkDownload: queued {}/{} entries", - enqueued, - request.entries.size() - ); + Log::info("addBulkDownload: queued {}/{} entries", enqueued, request.entries.size()); self->resetQueueThrottle(); reply({{"success", true}, {"enqueued", enqueued}}); @@ -1039,7 +1031,8 @@ void Session::registerRpcSftpAddBulkUploadOperation() const auto enqueued = self->operationQueue_->addBulkUploadOperation( *channel, request, - [&operationIdStrings](std::size_t idx) { + [&operationIdStrings](std::size_t idx) + { return Ids::makeOperationId(operationIdStrings[idx]); }, bulkCardId @@ -1315,10 +1308,7 @@ void Session::registerRpcSftpRecomputeSyncDiff() // Matches the OperationQueue::rpcName scheme // the frontend OperationQueue listens on. parent->hub_->callRemote( - fmt::format( - "OperationQueue::{}::onSyncDiffProgress", - parent->id_.value() - ), + fmt::format("OperationQueue::{}::onSyncDiffProgress", parent->id_.value()), syncSessionId, std::to_string(compared) ); @@ -1617,9 +1607,7 @@ void Session::registerRpcSftpAddBulkDeleteOperation() return reply({{"error", "Session no longer exists"}}); const auto enqueued = self->operationQueue_->addBulkDeleteOperation( - *channel, - request, - Ids::makeOperationId(bulkOperationIdString) + *channel, request, Ids::makeOperationId(bulkOperationIdString) ); Log::info("addBulkDelete: queued {} entries", enqueued); @@ -1642,9 +1630,7 @@ void Session::registerRpcSftpExistsBatch() on(fmt::format("Session::{}::sftp::existsBatch", id_.value())) .perform( [weak = weak_from_this()]( - RpcHelper::RpcOnce&& reply, - std::string const& channelIdString, - std::vector const& paths + RpcHelper::RpcOnce&& reply, std::string const& channelIdString, std::vector const& paths ) { auto self = weak.lock(); @@ -1655,23 +1641,59 @@ void Session::registerRpcSftpExistsBatch() Ids::makeChannelId(channelIdString), [paths](RpcHelper::RpcOnce&& reply, auto&& channel) { - std::vector results; - results.reserve(paths.size()); - for (auto const& path : paths) + // Group destinations by parent directory and list each parent once + // (readdir) rather than issuing one stat round-trip per path. A bulk + // drop targets a single directory, so this collapses N sequential + // SFTP round-trips into a single listing — a shallow existence diff, + // like sync does. A listing failure (e.g. the parent doesn't exist + // yet) degrades to "nothing exists", matching the prior per-stat + // fallback. + std::unordered_map>> listingByParent; + + auto listParent = [&channel]( + std::filesystem::path const& parent + ) -> std::optional> { - auto fut = channel->stat(Utility::pathFromUtf8(path)); + auto fut = channel->listDirectory(parent); if (fut.wait_for(futureTimeout) != std::future_status::ready) { Log::warn( - "sftp::existsBatch: stat timeout for '{}' (treating as not-exists)", path + "sftp::existsBatch: listing timed out for '{}' (treating contents as not-exists)", + parent.generic_string() ); - results.push_back(false); - continue; + return std::nullopt; } const auto result = fut.get(); - results.push_back(result.has_value()); + if (!result.has_value()) + return std::nullopt; + std::unordered_set names; + names.reserve(result->size()); + for (auto const& entry : *result) + names.insert(entry.path.filename().generic_string()); + return names; + }; + + std::vector results; + results.reserve(paths.size()); + for (auto const& path : paths) + { + const auto fsPath = Utility::pathFromUtf8(path); + const auto parentKey = fsPath.parent_path().generic_string(); + auto it = listingByParent.find(parentKey); + if (it == listingByParent.end()) + it = listingByParent.emplace(parentKey, listParent(fsPath.parent_path())).first; + + auto const& listing = it->second; + results.push_back( + listing.has_value() && + listing->find(fsPath.filename().generic_string()) != listing->end() + ); } - Log::info("sftp::existsBatch: probed {} paths", paths.size()); + Log::info( + "sftp::existsBatch: probed {} paths across {} dir listing(s)", + paths.size(), + listingByParent.size() + ); reply({{"success", true}, {"exists", results}}); }, std::move(reply) From 052d5900eeed2903a9559cb5a0bce1416abb9858 Mon Sep 17 00:00:00 2001 From: 5cript Date: Tue, 2 Jun 2026 23:53:45 +0200 Subject: [PATCH 11/11] Added cut off for file drops to avoid large listing. When there are 1000+ files in the remote but only <10 files are selected, a full listing is not necessary. --- backend/source/backend/session.cpp | 41 +++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/backend/source/backend/session.cpp b/backend/source/backend/session.cpp index c6e3566..13c2278 100644 --- a/backend/source/backend/session.cpp +++ b/backend/source/backend/session.cpp @@ -1641,13 +1641,40 @@ void Session::registerRpcSftpExistsBatch() Ids::makeChannelId(channelIdString), [paths](RpcHelper::RpcOnce&& reply, auto&& channel) { - // Group destinations by parent directory and list each parent once - // (readdir) rather than issuing one stat round-trip per path. A bulk - // drop targets a single directory, so this collapses N sequential - // SFTP round-trips into a single listing — a shallow existence diff, - // like sync does. A listing failure (e.g. the parent doesn't exist - // yet) degrades to "nothing exists", matching the prior per-stat - // fallback. + // Small drops: a handful of stat round-trips is cheaper than reading + // a potentially huge target directory in full. Above the threshold the + // single readdir below wins (one round-trip instead of N). + constexpr std::size_t listingThreshold = 10; + if (paths.size() < listingThreshold) + { + std::vector results; + results.reserve(paths.size()); + for (auto const& path : paths) + { + auto fut = channel->stat(Utility::pathFromUtf8(path)); + if (fut.wait_for(futureTimeout) != std::future_status::ready) + { + Log::warn( + "sftp::existsBatch: stat timeout for '{}' (treating as not-exists)", path + ); + results.push_back(false); + continue; + } + const auto result = fut.get(); + results.push_back(result.has_value()); + } + Log::info("sftp::existsBatch: probed {} paths via stat", paths.size()); + reply({{"success", true}, {"exists", results}}); + return; + } + + // Larger drops: group destinations by parent directory and list each + // parent once (readdir) rather than issuing one stat round-trip per + // path. A bulk drop targets a single directory, so this collapses N + // sequential SFTP round-trips into a single listing — a shallow + // existence diff, like sync does. A listing failure (e.g. the parent + // doesn't exist yet) degrades to "nothing exists", matching the + // per-stat fallback. std::unordered_map>> listingByParent; auto listParent = [&channel](