From fca50c99baa85b99f602f1482e14e1160ae74c71 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:20:22 +0200 Subject: [PATCH 01/62] The explorer filter keeps only selected items that stay visible Changing or clearing the explorer filter keeps only the items that were selected and visible, so items a filter hid while Ctrl+A or a shift range selected them never become selected when they show again; the index logic of the selection is a SelectionModel with native tests in the new nui-sftp-frontend-test --- .../side/selection_manager.hpp | 95 ++----- .../side/selection_model.hpp | 188 +++++++++++++ .../side/selection_manager.cpp | 254 ++++-------------- 3 files changed, 262 insertions(+), 275 deletions(-) create mode 100644 nui-file-explorer/include/nui-file-explorer/side/selection_model.hpp 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 669c4974..bbe63a84 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 @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -14,48 +15,15 @@ namespace NuiFileExplorer { - /// Manages item selection for both icon and table flavors. - /// - /// Two-layer model - /// --------------- - /// Layer 1 — Range layer (anchor_ + flag_) - /// The contiguous shift-selection. Shift+click and Shift+Arrow only ever - /// touch this layer. A plain click sets anchor_ and clears flag_ (single- - /// item range). Everything between anchor_ and flag_ (inclusive, min/max - /// order) is "in range". - /// - /// Layer 2 — Ctrl mask (ctrlAdd_ + ctrlRemove_) - /// An additive/subtractive mask applied on top of the range. - /// ctrlAdd_ – items that are selected regardless of the range. - /// ctrlRemove_ – items that are punched out of the range. - /// Ctrl+click dispatches to one of four cases (see onItemClicked). - /// This layer is NEVER touched by shift or plain-arrow operations. - /// - /// Visible selection = (range(anchor_, flag_) − ctrlRemove_) ∪ ctrlAdd_ - /// - /// Box-select (drag rectangle in icon flavor) - /// ------------------------------------------ - /// Plain drag → clears both ctrl sets, sets anchor_/flag_ to cover the - /// bounding-box items. Call selectRange(begin, end). - /// Ctrl+drag → leaves anchor_/flag_ alone, inserts intersecting items - /// into ctrlAdd_. Call ctrlAddRange(begin, end). - /// - /// Keyboard navigation — no wrapping, clamped at boundaries - /// --------------------------------------------------------- - /// Arrow (no modifier) → clear both ctrl sets, clear flag_, move anchor_ - /// one step; clamps at 0 / last item. - /// Shift+Arrow → move flag_ one step; clamps at 0 / last item. - /// Special boundary fill: - /// Shift+Down at last row → extend flag_ to the - /// last item (rightward fill to end of row). - /// Shift+Up at row 0 → extend flag_ to index 0 - /// (leftward fill to start of row). - /// Both ctrl sets are untouched. - /// Home / End → collapse to first selectable / last item; - /// clear both ctrl sets, clear flag_. - /// Shift+Home / Shift+End → extend flag_ to 0 / last item. - /// Ctrl+A → select all (clear ctrl sets, anchor_=0, - /// flag_=last). + /** + * @brief Selection of the items of one side for both flavors, the model is a @ref SelectionModel. + * + * Shift+click and Shift+Arrow move the flag, a plain click or arrow collapses to one item, Ctrl+click flips one + * item through the mask. Box select: a plain drag replaces the selection (@ref selectRange), a Ctrl+drag adds to + * it (@ref ctrlAddRange). Navigation clamps at the first and last item, Shift+Down on the last row and Shift+Up + * on the first row extend to the last and first item. Home lands on the first selectable item, Ctrl+A selects + * every selectable item. + */ class SelectionManager { public: @@ -91,10 +59,11 @@ namespace NuiFileExplorer void setPageJumpSize(std::size_t size); // ------------------------------------------------------------------ scroll - /// Register a callback that scrolls the item at the given flat index into - /// view. Called automatically after every user-driven selection change - /// (click, keyboard) with the "active index": flag_ if live, else anchor_. - /// Not called by selectAll / deselectAll / selectRange / ctrlAddRange. + /** + * @brief Registers a callback that scrolls the item at a flat index into view. Called after every user + * driven selection change (click, keyboard) with the active index of the model, not by selectAll, + * deselectAll, selectRange or ctrlAddRange. + */ void setScrollIntoViewCallback(std::function callback); /** @@ -104,7 +73,9 @@ namespace NuiFileExplorer void setVisibilityPredicate(std::function isVisible); /** - * @brief Re-applies the selection model, dropping items that are no longer visible. + * @brief Keeps the items selected now as an explicit selection and drops the ones that are no longer + * visible. Call it after the visibility changed, so items hidden while a range or select-all was made + * never become selected when they show again. */ void refreshSelection(); @@ -125,7 +96,9 @@ namespace NuiFileExplorer /// into view. Used by type-ahead. void jumpTo(std::size_t index); - /// Ctrl+box-drag: add items into ctrlAdd_ without touching the range layer. + /** + * @brief Ctrl+box-drag: adds the selectable items to the mask without touching the range. + */ void ctrlAddRange(std::size_t begin, std::size_t endInclusive); private: @@ -243,15 +216,11 @@ namespace NuiFileExplorer // ============================================================= helpers Grid makeGrid() const; - /// Rebuild visual isSelected() for every item from the two-layer model. - /// selected[i] = (inRange(i) && !ctrlRemove_.count(i)) || ctrlAdd_.count(i) + /** + * @brief Sets isSelected of every item from the model, only selectable items can be selected. + */ void rebuildSelection(); - /// Whether index i falls inside [min(anchor_,flag_), max(anchor_,flag_)]. - bool inRange(std::size_t i) const; - - static void rangeMinMax(std::size_t a, std::size_t b, std::size_t& lo, std::size_t& hi); - std::optional itemIndex(ItemWithInternals const& item) const; std::optional itemIndex(std::filesystem::path const& path) const; @@ -260,23 +229,15 @@ namespace NuiFileExplorer /// Apply one navigation step to 'from' according to the event key. std::size_t navigate(std::size_t from, Nui::WebApi::KeyboardEvent const& event) const; - /// Returns flag_ if active, else anchor_. nullopt if nothing is selected. - std::optional activeIndex() const; - - /// Fires scrollIntoView_ for the current activeIndex if it differs from - /// lastScrolledIndex_ and scrollIntoView_ is set. + /** + * @brief Scrolls the active index of the model into view when it changed since the last scroll. + */ void maybeScrollActiveIntoView(); private: Nui::Observed>* items_; - // Layer 1 — range - std::optional anchor_; ///< Fixed end of the shift-range / last plain click. - std::optional flag_; ///< Moving end; nullopt = single-item range at anchor_. - - // Layer 2 — ctrl mask - std::set ctrlAdd_; ///< Selected regardless of the range. - std::set ctrlRemove_; ///< Punched out of the range. + SelectionModel selection_{}; // Grid layout (updated by setGrid / setFlavor) Flavor currentFlavor_; diff --git a/nui-file-explorer/include/nui-file-explorer/side/selection_model.hpp b/nui-file-explorer/include/nui-file-explorer/side/selection_model.hpp new file mode 100644 index 00000000..a5b1d899 --- /dev/null +++ b/nui-file-explorer/include/nui-file-explorer/side/selection_model.hpp @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace NuiFileExplorer +{ + /** + * @brief The explorer selection by item index, in two layers. + * + * The range layer is the contiguous shift selection from the anchor to the flag (both inclusive, in either + * order); without a flag it is the anchor alone. The mask layer is applied on top of it: indices in the added set + * are selected regardless of the range, indices in the removed set are punched out of it. + * + * Selected = (range - removed) + added. Which indices may be selected at all (hidden by a filter, the ".." entry) + * is decided by the caller, see @ref selectedIndices. + */ + class SelectionModel + { + public: + /** + * @brief Forgets every selection. + */ + void clear() + { + anchor_.reset(); + flag_.reset(); + clearMask(); + } + + /** + * @brief Forgets the added and removed indices, the range stays. + */ + void clearMask() + { + added_.clear(); + removed_.clear(); + } + + /** + * @brief Selects only @p index, as a plain click does. + */ + void collapseTo(std::size_t index) + { + clearMask(); + flag_.reset(); + anchor_ = index; + } + + /** + * @brief Selects the range of all @p count indices, nothing when @p count is 0. + */ + void selectAll(std::size_t count) + { + clearMask(); + flag_.reset(); + anchor_.reset(); + if (count == 0) + return; + anchor_ = 0; + flag_ = count - 1; + } + + /** + * @brief Selects the range from @p begin to @p endInclusive only. + */ + void replaceRange(std::size_t begin, std::size_t endInclusive) + { + clearMask(); + anchor_ = begin; + flag_ = endInclusive; + } + + /** + * @brief Moves the flag to @p index, the anchor stays and is set to @p index when there is none. + */ + void extendTo(std::size_t index) + { + if (!anchor_) + anchor_ = index; + flag_ = index; + } + + /** + * @brief Selects @p index on top of the range. + */ + void add(std::size_t index) + { + removed_.erase(index); + added_.insert(index); + } + + /** + * @brief Deselects @p index without touching the range. + */ + void remove(std::size_t index) + { + added_.erase(index); + if (inRange(index)) + removed_.insert(index); + } + + /** + * @brief Flips @p index through the mask, as a Ctrl+click does. The range never changes. + */ + void toggleMasked(std::size_t index) + { + if (removed_.erase(index) > 0) + return; + if (added_.erase(index) > 0) + return; + if (inRange(index)) + removed_.insert(index); + else + added_.insert(index); + } + + /** + * @brief Keeps exactly @p selected as the selection, without a range. The anchor stays when it is one of + * them, so a following shift selection starts there. Called when the selectable indices change, so + * the range never takes in an index that could not be selected before. + */ + void pin(std::set selected) + { + flag_.reset(); + if (anchor_ && !selected.contains(*anchor_)) + anchor_.reset(); + removed_.clear(); + added_ = std::move(selected); + } + + bool inRange(std::size_t index) const + { + if (!anchor_) + return false; + const auto flag = flag_.value_or(*anchor_); + return index >= std::min(*anchor_, flag) && index <= std::max(*anchor_, flag); + } + + /** + * @brief Whether @p index belongs to the selection, ignoring whether it may be selected. + */ + bool contains(std::size_t index) const + { + return (inRange(index) && !removed_.contains(index)) || added_.contains(index); + } + + /** + * @brief The selected indices below @p count for which @p isSelectable holds. + */ + template + std::set selectedIndices(std::size_t count, PredicateT&& isSelectable) const + { + std::set result; + for (std::size_t index = 0; index < count; ++index) + if (contains(index) && isSelectable(index)) + result.insert(index); + return result; + } + + std::optional anchor() const + { + return anchor_; + } + + std::optional flag() const + { + return flag_; + } + + /** + * @brief The end the user moves: the flag, else the anchor, nullopt when there is neither. + */ + std::optional activeIndex() const + { + return flag_ ? flag_ : anchor_; + } + + private: + std::optional anchor_{}; + std::optional flag_{}; + std::set added_{}; + std::set removed_{}; + }; +} 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 4a19cdf0..caf955ab 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 @@ -13,20 +13,11 @@ namespace NuiFileExplorer scrollIntoView_ = std::move(callback); } - std::optional SelectionManager::activeIndex() const - { - if (flag_.has_value()) - return flag_; - if (anchor_.has_value()) - return anchor_; - return std::nullopt; - } - void SelectionManager::maybeScrollActiveIntoView() { if (!scrollIntoView_) return; - const auto idx = activeIndex(); + const auto idx = selection_.activeIndex(); if (!idx.has_value()) return; if (lastScrolledIndex_ == idx) @@ -73,34 +64,6 @@ namespace NuiFileExplorer return Grid{gridColumns_, gridRows_, items_->value().size(), currentFlavor_}; } - void SelectionManager::rangeMinMax(std::size_t a, std::size_t b, std::size_t& lo, std::size_t& hi) - { - if (a <= b) - { - lo = a; - hi = b; - } - else - { - lo = b; - hi = a; - } - } - - bool SelectionManager::inRange(std::size_t i) const - { - if (!anchor_.has_value()) - return false; - - std::size_t lo, hi; - if (flag_.has_value()) - rangeMinMax(*anchor_, *flag_, lo, hi); - else - lo = hi = *anchor_; - - return i >= lo && i <= hi; - } - std::optional SelectionManager::itemIndex(ItemWithInternals const& item) const { const auto& vec = items_->value(); @@ -134,6 +97,12 @@ namespace NuiFileExplorer void SelectionManager::refreshSelection() { + const auto& vec = items_->value(); + std::set shown; + for (std::size_t index = 0; index < vec.size(); ++index) + if (vec[index].isSelected()) + shown.insert(index); + selection_.pin(std::move(shown)); rebuildSelection(); } @@ -146,9 +115,7 @@ namespace NuiFileExplorer auto& vec = items_->value(); for (std::size_t i = 0; i < vec.size(); ++i) { - const bool selected = isSelectablePath(i) && - ((inRange(i) && ctrlRemove_.count(i) == 0) || ctrlAdd_.count(i) > 0); - vec[i].isSelected(selected); + vec[i].isSelected(isSelectablePath(i) && selection_.contains(i)); } Nui::globalEventContext.sync(); } @@ -159,10 +126,7 @@ namespace NuiFileExplorer void SelectionManager::loseTrackToAllSelections() { - anchor_.reset(); - flag_.reset(); - ctrlAdd_.clear(); - ctrlRemove_.clear(); + selection_.clear(); } void SelectionManager::deselectAll() @@ -175,39 +139,21 @@ namespace NuiFileExplorer void SelectionManager::selectAll() { - ctrlAdd_.clear(); - ctrlRemove_.clear(); - flag_.reset(); - const auto n = items_->value().size(); - if (n > 0) - { - anchor_ = 0; - flag_ = n - 1; - } - else - { - anchor_.reset(); - } + selection_.selectAll(items_->value().size()); rebuildSelection(); } - // select/deselect/toggle operate on ctrlAdd_ so they compose cleanly with - // any existing range layer. - void SelectionManager::select(std::size_t index) { if (!isSelectablePath(index)) return; - ctrlRemove_.erase(index); // un-punch if it was punched out - ctrlAdd_.insert(index); + selection_.add(index); rebuildSelection(); } void SelectionManager::deselect(std::size_t index) { - ctrlAdd_.erase(index); - if (inRange(index)) - ctrlRemove_.insert(index); // punch it out of the range + selection_.remove(index); rebuildSelection(); } @@ -249,11 +195,7 @@ namespace NuiFileExplorer void SelectionManager::selectRange(std::size_t begin, std::size_t endInclusive) { - // Plain drag: replace both layers entirely. - ctrlAdd_.clear(); - ctrlRemove_.clear(); - anchor_ = begin; - flag_ = endInclusive; + selection_.replaceRange(begin, endInclusive); rebuildSelection(); } @@ -261,25 +203,17 @@ namespace NuiFileExplorer { if (index >= items_->value().size()) return; - ctrlAdd_.clear(); - ctrlRemove_.clear(); - flag_.reset(); - anchor_ = index; + selection_.collapseTo(index); rebuildSelection(); maybeScrollActiveIntoView(); } void SelectionManager::ctrlAddRange(std::size_t begin, std::size_t endInclusive) { - // Ctrl+drag: add items to ctrlAdd_, don't touch the range layer. - std::size_t lo, hi; - rangeMinMax(begin, endInclusive, lo, hi); - for (std::size_t i = lo; i <= hi; ++i) + for (std::size_t index = std::min(begin, endInclusive); index <= std::max(begin, endInclusive); ++index) { - if (!isSelectablePath(i)) - continue; - ctrlRemove_.erase(i); // restore any previously punched-out item - ctrlAdd_.insert(i); + if (isSelectablePath(index)) + selection_.add(index); } rebuildSelection(); } @@ -354,74 +288,24 @@ namespace NuiFileExplorer const bool ctrl = event.ctrlKey() || event.metaKey(); const bool shift = event.shiftKey(); - if (shift && !ctrl) + if (ctrl && !shift) { - // ---- Shift+click: move flag, keep anchor, leave ctrl mask alone ---- - if (!anchor_.has_value()) - anchor_ = idx; - flag_ = idx; - rebuildSelection(); - maybeScrollActiveIntoView(); - } - else if (ctrl && !shift) - { - // ---- Ctrl+click: toggle via the ctrl mask; never touch anchor/flag ---- - // - // Four cases: - // 1. In ctrlRemove_ (punched out of range) → restore to range: - // remove from ctrlRemove_. - // 2. In ctrlAdd_ (explicitly added) → remove from ctrlAdd_. - // 3. In range (selected via range, not punched) → punch out: - // insert into ctrlRemove_. - // 4. Not selected at all → add to ctrlAdd_. - // - // After this, anchor_/flag_ are completely unchanged. - if (ctrlRemove_.count(idx)) - { - // Case 1: was punched out — restore it. - ctrlRemove_.erase(idx); - } - else if (ctrlAdd_.count(idx)) - { - // Case 2: was explicitly added — remove it. - ctrlAdd_.erase(idx); - } - else if (inRange(idx)) - { - // Case 3: selected via range — punch it out. - ctrlRemove_.insert(idx); - } - else - { - // Case 4: not selected — add it. - ctrlAdd_.insert(idx); - } + selection_.toggleMasked(idx); rebuildSelection(); - // Ctrl+click does not move anchor/flag, so we scroll to the clicked - // item directly — it is the one the user just interacted with. + // Ctrl+click moves neither end of the range, the clicked item is the one to show. lastScrolledIndex_ = idx; if (scrollIntoView_) scrollIntoView_(idx); + return; } - else if (ctrl && shift) - { - // ---- Ctrl+Shift+click: extend the range but keep the ctrl mask ---- - if (!anchor_.has_value()) - anchor_ = idx; - flag_ = idx; - rebuildSelection(); - maybeScrollActiveIntoView(); - } + + // Shift+click and Ctrl+Shift+click extend the range and keep the mask, a plain click selects only this item. + if (shift) + selection_.extendTo(idx); else - { - // ---- Plain click: reset everything, select only this item ---- - ctrlAdd_.clear(); - ctrlRemove_.clear(); - flag_.reset(); - anchor_ = idx; - rebuildSelection(); - maybeScrollActiveIntoView(); - } + selection_.collapseTo(idx); + rebuildSelection(); + maybeScrollActiveIntoView(); } // ========================================================================= @@ -482,22 +366,11 @@ namespace NuiFileExplorer }; if (shift) - { - if (!flag_.has_value()) - flag_ = anchor_.value_or(0); - flag_ = jump(*flag_); - rebuildSelection(); - maybeScrollActiveIntoView(); - } + selection_.extendTo(jump(selection_.activeIndex().value_or(0))); else - { - ctrlAdd_.clear(); - ctrlRemove_.clear(); - flag_.reset(); - anchor_ = jump(anchor_.value_or(0)); - rebuildSelection(); - maybeScrollActiveIntoView(); - } + selection_.collapseTo(jump(selection_.anchor().value_or(0))); + rebuildSelection(); + maybeScrollActiveIntoView(); return true; } @@ -507,63 +380,28 @@ namespace NuiFileExplorer return true; if (shift) - { - // Extend flag_ to the boundary; anchor and ctrl mask untouched. - if (!flag_.has_value()) - flag_ = anchor_.value_or(0); - flag_ = isEnd ? (n - 1) : 0; - rebuildSelection(); - maybeScrollActiveIntoView(); - } + selection_.extendTo(isEnd ? n - 1 : 0); + else if (isEnd) + selection_.collapseTo(n - 1); else { - // Plain Home/End: collapse to a single item at the boundary. - ctrlAdd_.clear(); - ctrlRemove_.clear(); - flag_.reset(); - if (isEnd) - { - anchor_ = n - 1; - } - else - { - // Home: land on the first selectable item (skip ".."). - anchor_ = 0; - while (*anchor_ < n && !isSelectablePath(*anchor_)) - anchor_ = *anchor_ + 1; - if (*anchor_ >= n) - anchor_ = 0; - } - rebuildSelection(); - maybeScrollActiveIntoView(); + // Home lands on the first selectable item, past "..". + std::size_t first = 0; + while (first < n && !isSelectablePath(first)) + ++first; + selection_.collapseTo(first < n ? first : 0); } + rebuildSelection(); + maybeScrollActiveIntoView(); return true; } if (shift) - { - // Move flag; anchor and ctrl mask are untouched. - // If no range is active yet, start the flag from the anchor (or 0). - if (!flag_.has_value()) - flag_ = anchor_.value_or(0); - - flag_ = navigate(*flag_, event); - rebuildSelection(); - maybeScrollActiveIntoView(); - } + selection_.extendTo(navigate(selection_.activeIndex().value_or(0), event)); else - { - // Plain arrow: drop the ctrl mask entirely and collapse to a single item. - ctrlAdd_.clear(); - ctrlRemove_.clear(); - flag_.reset(); - - const std::size_t next = navigate(anchor_.value_or(0), event); - anchor_ = next; - rebuildSelection(); - maybeScrollActiveIntoView(); - } - + selection_.collapseTo(navigate(selection_.anchor().value_or(0), event)); + rebuildSelection(); + maybeScrollActiveIntoView(); return true; } } \ No newline at end of file From fdb122c3737b8344288e230072fd0dc0cb670e46 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:26:56 +0200 Subject: [PATCH 02/62] Explorer uploads and downloads share one flow in SideModel Uploads and downloads from the explorer share one flow in SideModel: the candidates, the transfer question, the existence check (FileEngine::existsBatchLocal for local destinations) and the overwrite questions decided by OverwriteDecisions, which accepts an absent destination without consent to overwrite and "All" only for existing ones; the question for a single download names the destination like the one for an upload --- .../file_explorer/local_side_model.hpp | 11 +- .../file_explorer/overwrite_decisions.hpp | 127 ++++++ .../file_explorer/remote_side_model.hpp | 11 +- .../frontend/file_explorer/side_model.hpp | 358 ++++++++++++++++ .../frontend/file_explorer/side_paths.hpp | 148 +++++++ .../include/frontend/terminal/file_engine.hpp | 14 +- .../file_explorer/local_side_model.cpp | 312 +++----------- .../file_explorer/remote_side_model.cpp | 384 +++--------------- .../frontend/file_explorer/side_model.cpp | 13 + .../source/frontend/terminal/file_engine.cpp | 89 ++-- 10 files changed, 821 insertions(+), 646 deletions(-) create mode 100644 frontend/include/frontend/file_explorer/overwrite_decisions.hpp create mode 100644 frontend/include/frontend/file_explorer/side_paths.hpp diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 0381147c..be70d6af 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -140,16 +140,9 @@ class LocalSideModel std::expected localPathOf(NuiFileExplorer::Item const& item) const; /** - * @brief Uploads @p candidates, asking per existing destination whether to overwrite it. + * @brief Queues the uploads the user accepted, a single file as a transfer of its own. */ - void uploadItemsConfirmed( - std::vector candidates, - std::shared_ptr> existsResults, - std::size_t index = 0, - bool overwriteNever = false, - bool overwriteAlways = false, - std::shared_ptr> accepted = nullptr - ); + void enqueueUploads(std::vector accepted); void enqueueSingleUpload(SharedData::BulkUploadEntry const& entry, bool allowOverwrite, bool insertRefresh); diff --git a/frontend/include/frontend/file_explorer/overwrite_decisions.hpp b/frontend/include/frontend/file_explorer/overwrite_decisions.hpp new file mode 100644 index 00000000..866083bf --- /dev/null +++ b/frontend/include/frontend/file_explorer/overwrite_decisions.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include + +namespace FileExplorer +{ + /** + * @brief The user's answer to "the destination exists, overwrite it?". + */ + enum class OverwriteAnswer + { + /** @brief Overwrite this destination. */ + Yes, + /** @brief Skip this candidate. */ + No, + /** @brief Overwrite this and every later existing destination. */ + All, + /** @brief Skip this and every later candidate whose destination exists. */ + None, + /** @brief Stop asking, only what was accepted so far is transferred. */ + Cancel, + }; + + /** + * @brief @p entry with the user's consent to replace its existing destination. + */ + template + EntryT approvedForOverwrite(EntryT entry) + { + entry.mayOverwrite = true; + return entry; + } + + /** + * @brief Decides which transfer candidates go ahead, asking about each one whose destination exists. + * + * A candidate whose destination did not exist when probed is accepted without consent to overwrite, so one that + * appears before the transfer runs fails instead of being replaced. Only candidates the user approved (Yes, All) + * may replace their destination. + */ + template + class OverwriteDecisions + { + public: + /** + * @param candidates The entries to transfer, each with a destination and a mayOverwrite flag. + * @param exists Index aligned with @p candidates: whether the destination exists. A missing answer counts as + * existing, so the user is asked. + */ + OverwriteDecisions(std::vector candidates, std::vector exists) + : candidates_{std::move(candidates)} + , exists_{std::move(exists)} + {} + + /** + * @brief Accepts or skips every candidate up to the next one that needs an answer. + * + * @return That candidate, nullptr when every candidate is decided. + */ + EntryT const* nextQuestion() + { + while (next_ < candidates_.size()) + { + if (!destinationExists(next_)) + accepted_.push_back(candidates_[next_]); + else if (overwriteAll_) + accepted_.push_back(approvedForOverwrite(candidates_[next_])); + else if (!skipAll_) + return &candidates_[next_]; + ++next_; + } + return nullptr; + } + + /** + * @brief Applies @p answer to the candidate @ref nextQuestion returned. + */ + void answer(OverwriteAnswer answer) + { + if (next_ >= candidates_.size()) + return; + switch (answer) + { + case OverwriteAnswer::All: + overwriteAll_ = true; + [[fallthrough]]; + case OverwriteAnswer::Yes: + accepted_.push_back(approvedForOverwrite(candidates_[next_])); + ++next_; + return; + case OverwriteAnswer::None: + skipAll_ = true; + [[fallthrough]]; + case OverwriteAnswer::No: + ++next_; + return; + case OverwriteAnswer::Cancel: + next_ = candidates_.size(); + return; + } + } + + /** + * @brief The accepted candidates in their order, taken out of this object. + */ + std::vector takeAccepted() + { + return std::exchange(accepted_, {}); + } + + private: + bool destinationExists(std::size_t index) const + { + return index >= exists_.size() || exists_[index]; + } + + private: + std::vector candidates_; + std::vector exists_; + std::vector accepted_{}; + std::size_t next_{0}; + bool overwriteAll_{false}; + bool skipAll_{false}; + }; +} diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index b20603ee..729b1656 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -137,16 +137,9 @@ class RemoteSideModel std::expected childOfCurrentPath(std::string_view name) const; /** - * @brief Downloads @p candidates, asking per existing destination whether to overwrite it. + * @brief Queues the downloads the user accepted, a single file as a transfer of its own. */ - void downloadItemsConfirmed( - std::vector candidates, - std::shared_ptr> existsResults, - std::size_t index = 0, - bool overwriteNever = false, - bool overwriteAlways = false, - std::shared_ptr> accepted = nullptr - ); + void enqueueDownloads(std::vector accepted); void enqueueSingleDownload(SharedData::BulkDownloadEntry const& entry, bool allowOverwrite, bool insertRefresh); diff --git a/frontend/include/frontend/file_explorer/side_model.hpp b/frontend/include/frontend/file_explorer/side_model.hpp index 11be4529..3c69b7d2 100644 --- a/frontend/include/frontend/file_explorer/side_model.hpp +++ b/frontend/include/frontend/file_explorer/side_model.hpp @@ -4,16 +4,32 @@ #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 // This should be an assertion, but assert failures are hard to debug in the frontend, // log messages and noop is much easier to deal with. @@ -138,6 +154,348 @@ class SideModel : public NuiFileExplorer::ISideModel */ void confirmReplaceOnRename(std::string const& name, std::function onReplace); + /** + * @brief Texts of the questions a transfer asks. + */ + struct TransferPrompt + { + std::string headerText{}; + /** @brief Question for one item, formatted with the destination name and @ref destinationDirectory. */ + std::string singleText{}; + /** @brief Question for several items, formatted with their count and @ref destinationDirectory. */ + std::string multipleText{}; + std::string destinationDirectory{}; + std::string overwriteHeaderText{}; + std::string overwriteText{}; + }; + + /** + * @brief One transfer entry per item of @p items: the source @p sourceOf gives for it, the destination with the + * source's name in @p destinationDirectory. Refused when an item names no source or its name cannot be + * used at the destination. + */ + template + static std::expected, Paths::PathError> transferCandidates( + std::vector const& items, + SourceOfT const& sourceOf, + DirectoryT const& destinationDirectory + ) + { + std::vector candidates; + candidates.reserve(items.size()); + for (auto const& item : items) + { + auto source = sourceOf(item); + if (!source) + return std::unexpected(source.error()); + const auto name = source->filename(); + if (!name) + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::Empty, .input = FileExplorer::pathText(*source)} + ); + auto destination = FileExplorer::childOf(destinationDirectory, *name); + if (!destination) + return std::unexpected(destination.error()); + EntryT entry{ + .source = *std::move(source), + .destination = *std::move(destination), + .sizeBytes = item.size, + .isDirectory = item.isDirectory(), + }; + if constexpr (requires { entry.mtime; }) + { + entry.mtime = item.mtime; + entry.mtimeNsec = item.mtimeNsec; + } + candidates.push_back(std::move(entry)); + } + return candidates; + } + + /** + * @brief Asks whether to transfer @p candidates, checks which destinations exist, asks about each existing one + * and hands the accepted entries to @p onAccepted. Nothing is transferred when the check fails. + */ + template + void confirmTransfer( + std::vector candidates, + TransferPrompt prompt, + std::function)> onAccepted + ) + { + if (candidates.empty()) + return; + const auto text = candidates.size() > 1 + ? fmt::format(fmt::runtime(prompt.multipleText), candidates.size(), prompt.destinationDirectory) + : fmt::format( + fmt::runtime(prompt.singleText), + candidates.front().destination.filename()->view(), + prompt.destinationDirectory + ); + std::vector listItems; + listItems.reserve(candidates.size()); + std::ranges::transform( + candidates, + std::back_inserter(listItems), + [](EntryT const& candidate) + { + return ConfirmDialog::OpenOptions::ListElement{.text = FileExplorer::pathText(candidate.source)}; + } + ); + + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Primary, + .headerText = prompt.headerText, + .text = text, + .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, + .focusButton = ConfirmDialog::Button::Yes, + .listItems = std::move(listItems), + .onClose = lifetime_.guarded( + [this, candidates = std::move(candidates), prompt, onAccepted = std::move(onAccepted)]( + std::optional button + ) mutable + { + if (button != ConfirmDialog::Button::Yes) + { + Log::info("Transfer cancelled"); + return; + } + checkDestinations(std::move(candidates), std::move(prompt), std::move(onAccepted)); + } + ), + }); + } + + /** + * @brief Asks for a new name of @p item and renames it at @p from, next to itself. Refuses an item that names no + * path. + */ + template + void promptRename(NuiFileExplorer::Item const& item, std::expected const& from) + { + if (!from) + return showPathRefused( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameFailed"), from.error() + ); + + inputDialog_->open({ + .whatFor = language->get(isLeft() ? "localSideModel" : "remoteSideModel", "rename"), + .prompt = fmt::format( + fmt::runtime(language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renamePrompt")), + item.name + ), + .headerText = fmt::format( + fmt::runtime(language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameWithItem")), + item.name + ), + .initialValue = item.name, + .isPassword = false, + .onConfirm = lifetime_.guarded( + [this, from = *from, currentName = item.name](std::optional const& name) + { + if (!name || name->empty() || *name == currentName) + return; + const auto directory = from.parent(); + if (!directory) + return showPathRefused( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameFailed"), + Paths::PathError{ + .reason = Paths::PathRejection::EscapesRoot, .input = FileExplorer::pathText(from) + } + ); + const auto to = FileExplorer::childOf(*directory, std::string_view{*name}); + if (!to) + return showInvalidItemName(to.error()); + + Log::info("Renaming '{}' to '{}'.", FileExplorer::pathText(from), *name); + renameItem(from, *to, false); + } + ), + }); + } + + /** + * @brief Asks for the name of a new item of @p type and hands its path in the listed directory to @p create. + * Refuses a name the side cannot use for a single item. + */ + template + void promptNewItem(NuiFileExplorer::Item::Type type, std::function create) + { + if (type != NuiFileExplorer::Item::Type::Directory && type != NuiFileExplorer::Item::Type::Regular) + return Log::error("New items of type {} are not supported.", static_cast(type)); + + const bool isDirectory = type == NuiFileExplorer::Item::Type::Directory; + inputDialog_->open({ + .whatFor = language->get( + isLeft() ? "localSideModel" : "remoteSideModel", isDirectory ? "newDirectory" : "newFile" + ), + .prompt = language->get( + isLeft() ? "localSideModel" : "remoteSideModel", + isDirectory ? "enterNewDirectoryName" : "enterNewFileName" + ), + .headerText = language->get( + isLeft() ? "localSideModel" : "remoteSideModel", isDirectory ? "createNewDirectory" : "createNewFile" + ), + .isPassword = false, + .onConfirm = lifetime_.guarded( + [this, create = std::move(create)](std::optional const& name) + { + if (!name) + return; + const auto directory = FileExplorer::parsePath(listedPath_.generic_string()); + if (!directory) + return showPathRefused( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "createItemFailedTitle"), + directory.error() + ); + const auto path = FileExplorer::childOf(*directory, std::string_view{*name}); + if (!path) + return showInvalidItemName(path.error()); + Log::info("Creating new item: {}", FileExplorer::pathText(*path)); + create(*path); + } + ), + }); + } + + /** + * @brief Renames @p from to @p to, asks before replacing an existing item and retries with @p overwrite. + */ + template + void renameItem(PathT const& from, PathT const& to, bool overwrite) + { + auto onRenamed = lifetime_.guarded( + [this, from, to, overwrite](bool success, std::string const& info) + { + if (success) + { + Log::info("Renamed '{}' to '{}'.", FileExplorer::pathText(from), FileExplorer::pathText(to)); + return onRefresh(); + } + if (info == "backend.rename.targetExists" && !overwrite) + { + return confirmReplaceOnRename( + std::string{to.filename()->view()}, + lifetime_.guarded( + [this, from, to]() + { + renameItem(from, to, true); + } + ) + ); + } + const auto reason = language->translate(info); + Log::error( + "Failed to rename '{}' to '{}': {}", FileExplorer::pathText(from), FileExplorer::pathText(to), reason + ); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameFailed"), + .text = fmt::format( + fmt::runtime( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "failedToRenameItem") + ), + FileExplorer::pathText(from), + FileExplorer::pathText(to), + reason + ), + .buttons = ConfirmDialog::Button::Ok, + }); + } + ); + if constexpr (std::is_same_v) + FileEngine::renameLocal(from, to, overwrite, std::move(onRenamed)); + else + fileEngine_->rename(from, to, overwrite, std::move(onRenamed)); + } + + private: + template + void checkDestinations( + std::vector candidates, + TransferPrompt prompt, + std::function)> onAccepted + ) + { + using DestinationT = decltype(EntryT::destination); + std::vector destinations; + destinations.reserve(candidates.size()); + std::ranges::transform(candidates, std::back_inserter(destinations), &EntryT::destination); + + auto onExists = lifetime_.guarded( + [this, candidates = std::move(candidates), prompt = std::move(prompt), onAccepted = std::move(onAccepted)]( + std::vector exists, std::string const& info + ) mutable + { + // An unknown destination is never taken for absent, the transfer stops instead. + if (exists.size() != candidates.size()) + return showExistenceCheckFailed( + exists.empty() ? language->translate(info) + : fmt::format("{} of {} answers", exists.size(), candidates.size()) + ); + askOverwrites( + std::make_shared>(std::move(candidates), std::move(exists)), + std::make_shared(std::move(prompt)), + std::move(onAccepted) + ); + } + ); + if constexpr (std::is_same_v) + FileEngine::existsBatchLocal(destinations, std::move(onExists)); + else + fileEngine_->existsBatchRemote(destinations, std::move(onExists)); + } + + template + void askOverwrites( + std::shared_ptr> decisions, + std::shared_ptr prompt, + std::function)> onAccepted + ) + { + const auto* question = decisions->nextQuestion(); + if (question == nullptr) + { + auto accepted = decisions->takeAccepted(); + if (!accepted.empty()) + onAccepted(std::move(accepted)); + return; + } + + const auto destination = FileExplorer::pathText(question->destination); + Log::info("The destination '{}' exists, asking whether to overwrite it.", destination); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Primary, + .headerText = prompt->overwriteHeaderText, + .text = prompt->overwriteText, + .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No | ConfirmDialog::Button::All | + ConfirmDialog::Button::None | ConfirmDialog::Button::Cancel, + .focusButton = ConfirmDialog::Button::No, + .listItems = {{.text = destination}}, + .onClose = lifetime_.guarded( + [this, decisions, prompt, onAccepted](std::optional button) + { + decisions->answer(overwriteAnswerOf(button)); + askOverwrites(decisions, prompt, onAccepted); + } + ), + }); + } + + void notifyFavoritesChanged(); + + /** + * @brief The icon URL the grid shows for @p entry. + */ + std::string iconOf(SharedData::DirectoryEntry const& entry) const; + + /** + * @brief The answer a button of the overwrite question stands for, closing the dialog cancels. + */ + static FileExplorer::OverwriteAnswer overwriteAnswerOf(std::optional button); + + protected: + /** * @brief The directory has a parent to offer as "..". */ diff --git a/frontend/include/frontend/file_explorer/side_paths.hpp b/frontend/include/frontend/file_explorer/side_paths.hpp new file mode 100644 index 00000000..72f8a410 --- /dev/null +++ b/frontend/include/frontend/file_explorer/side_paths.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace FileExplorer +{ + /** + * @brief The drop target that stands for the parent directory, the ".." entry of a listing. + */ + inline constexpr std::string_view parentDirectoryTarget{".."}; + + inline std::string const& pathText(Paths::RemotePath const& path) + { + return path.value(); + } + + inline std::string const& pathText(Paths::LocalPath const& path) + { + return path.toUtf8(); + } + + /** + * @brief Parses the UTF-8 text of an absolute path of the side @p PathT names. + */ + template + std::expected parsePath(std::string_view utf8); + + template <> + inline std::expected parsePath(std::string_view utf8) + { + return Paths::RemotePath::make(utf8); + } + + template <> + inline std::expected parsePath(std::string_view utf8) + { + return Paths::LocalPath::fromUtf8(utf8); + } + + /** + * @brief @p name directly below @p directory, refused when it is no single name the server allows. + */ + inline std::expected + childOf(Paths::RemotePath const& directory, std::string_view name) + { + return Paths::PathSegment::make(name).transform( + [&directory](Paths::PathSegment const& segment) + { + return directory / segment; + } + ); + } + + inline std::expected + childOf(Paths::RemotePath const& directory, Paths::PathSegment const& name) + { + return directory / name; + } + + /** + * @brief @p name directly below @p directory, refused when it is no single name the platform of @p directory + * allows. + */ + inline std::expected + childOf(Paths::LocalPath const& directory, std::string_view name) + { + return Paths::LocalPathSegment::make(name, directory.platform()) + .transform( + [&directory](Paths::LocalPathSegment const& segment) + { + return directory / segment; + } + ); + } + + inline std::expected + childOf(Paths::LocalPath const& directory, Paths::PathSegment const& name) + { + return Paths::LocalPathSegment::make(name, directory.platform()) + .transform( + [&directory](Paths::LocalPathSegment const& segment) + { + return directory / segment; + } + ); + } + + /** + * @brief The directory a drop or transfer goes into: @p directory itself without @p target, its parent for + * @ref parentDirectoryTarget, else the child named @p target. + */ + template + std::expected + targetDirectoryOf(PathT const& directory, std::optional const& target) + { + if (!target) + return directory; + if (*target == parentDirectoryTarget) + { + if (auto parent = directory.parent(); parent) + return *std::move(parent); + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::EscapesRoot, .input = std::string{*target}} + ); + } + return childOf(directory, *target); + } + + /** + * @brief The parent of the listed @p directory, nullopt for a root (like "/", "C:/" or "//server/share") and + * for text that is no absolute path of the side. + */ + template + std::optional parentOf(std::string_view directory) + { + const auto parsed = parsePath(directory); + if (!parsed) + return std::nullopt; + return parsed->parent().transform( + [](PathT const& parent) + { + return pathText(parent); + } + ); + } + + /** + * @brief The directory to list for @p path: the normalized absolute path of the side, which has no trailing + * separator except at a root. Text the side does not take as an absolute path is normalized lexically, + * without trailing separators, and the listing then reports why it fails. + */ + template + std::string navigationTarget(std::string_view path) + { + if (const auto parsed = parsePath(path); parsed) + return pathText(*parsed); + auto normal = std::filesystem::path{std::string{path}}.lexically_normal().generic_string(); + while (normal.size() > 1 && normal.back() == '/') + normal.pop_back(); + return normal; + } +} diff --git a/frontend/include/frontend/terminal/file_engine.hpp b/frontend/include/frontend/terminal/file_engine.hpp index 8efb16e1..3b340b08 100644 --- a/frontend/include/frontend/terminal/file_engine.hpp +++ b/frontend/include/frontend/terminal/file_engine.hpp @@ -279,9 +279,7 @@ class FileEngine ); /** - * @brief Batched remote-existence probe. Replaces N per-file `sftp::stat` - * round-trips with a single one — used to gate bulk uploads on - * per-file conflict prompts without the latency cost. + * @brief Asks the server in one request which of @p paths exist. * * @param paths Remote paths to probe. * @param onComplete Receives a vector aligned with @p paths. On @@ -293,6 +291,16 @@ class FileEngine std::function exists, std::string const& info)> onComplete ); + /** + * @brief Asks the backend in one request which of the local @p paths exist, needs no connection. + * + * @param onComplete Same as for @ref existsBatchRemote. + */ + static void existsBatchLocal( + std::vector const& paths, + std::function exists, std::string const& info)> onComplete + ); + void dispose(std::function onComplete); private: diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index 0e885107..3e5c8529 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -645,93 +645,40 @@ void LocalSideModel::onTransfer( showPathRefused(language->get("localSideModel", "transferFailedTitle"), error); }; const auto destinationDirectory = remoteModel_->currentRemotePath().and_then( - [&subDir](Paths::RemotePath const& directory) -> std::expected + [&subDir](Paths::RemotePath const& directory) { - if (!subDir) - return directory; - return Paths::PathSegment::make(*subDir).transform( - [&directory](Paths::PathSegment const& segment) - { - return directory / segment; - } - ); + return FileExplorer::targetDirectoryOf(directory, subDir); } ); if (!destinationDirectory) return refuse(destinationDirectory.error()); - - std::vector candidates; - candidates.reserve(items.size()); - for (auto const& item : items) - { - auto source = localPathOf(item); - if (!source) - return refuse(source.error()); - const auto name = source->filename(); - if (!name) - return refuse(Paths::PathError{.reason = Paths::PathRejection::Empty, .input = source->toUtf8()}); - candidates.push_back( - SharedData::BulkUploadEntry{ - .source = *source, - .destination = *destinationDirectory / *name, - .sizeBytes = item.size, - .isDirectory = item.isDirectory(), - } - ); - } - - const auto itemsSize = candidates.size(); - std::string confirmText = fmt::format( - fmt::runtime( - language->get("localSideModel", itemsSize > 1 ? "uploadMultipleConfirmText" : "uploadSingleConfirmText") - ), - itemsSize > 1 ? std::to_string(itemsSize) : std::string{candidates.front().destination.filename()->view()}, - destinationDirectory->value() + auto candidates = transferCandidates( + items, + [this](NuiFileExplorer::Item const& item) + { + return localPathOf(item); + }, + *destinationDirectory ); + if (!candidates) + return refuse(candidates.error()); - std::vector listItems; - for (const auto& candidate : candidates) - listItems.push_back({candidate.source.toUtf8(), ""}); - - confirmDialog_->open( - {.styleVariant = ScriptNuiComponents::StyleVariant::Primary, + confirmTransfer( + *std::move(candidates), + { .headerText = language->get("localSideModel", "uploadConfirmTitle"), - .text = confirmText, - .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, - .focusButton = ConfirmDialog::Button::Yes, - .listItems = listItems, - .onClose = [this, candidates = std::move(candidates)](std::optional button) mutable + .singleText = language->get("localSideModel", "uploadSingleConfirmText"), + .multipleText = language->get("localSideModel", "uploadMultipleConfirmText"), + .destinationDirectory = destinationDirectory->value(), + .overwriteHeaderText = language->get("localSideModel", "fileAlreadyExistsOverwriteHeader"), + .overwriteText = language->get("localSideModel", "fileAlreadyExistsOverwrite"), + }, + lifetime_.guarded( + [this](std::vector accepted) { - if (!button || *button != ConfirmDialog::Button::Yes) - { - Log::info("Upload items cancelled"); - return; - } - - Log::info("Uploading items"); - - // Batched remote-existence probe, one RPC for the whole batch instead of N per-file SFTP stats. - std::vector destinations; - destinations.reserve(candidates.size()); - for (auto const& candidate : candidates) - destinations.push_back(candidate.destination); - - fileEngine_->existsBatchRemote( - destinations, - [this, - candidates = std::move(candidates)](std::vector exists, std::string const& info) mutable - { - // An unknown destination is never taken for absent, the batch overwrites what it accepts. - if (exists.size() != candidates.size()) - return showExistenceCheckFailed( - exists.empty() ? info : fmt::format("{} of {} answers", exists.size(), candidates.size()) - ); - uploadItemsConfirmed( - std::move(candidates), std::make_shared>(std::move(exists)) - ); - } - ); - }} + enqueueUploads(std::move(accepted)); + } + ) ); } @@ -1131,198 +1078,31 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) ); } -namespace +void LocalSideModel::enqueueUploads(std::vector accepted) { - /** - * @brief @p entry with the user's consent to replace its existing destination. - */ - template - EntryT approvedForOverwrite(EntryT entry) - { - entry.mayOverwrite = true; - return entry; - } -} - -void LocalSideModel::uploadItemsConfirmed( - std::vector candidates, - std::shared_ptr> existsResults, - std::size_t index, - bool overwriteNever, - bool overwriteAlways, - std::shared_ptr> accepted -) -{ - // Mirrors RemoteSideModel::downloadItemsConfirmed — see that for the - // shape rationale (iterate sync, recurse only on conflict prompts to - // keep WASM stack depth bounded by conflicts not by file count). - - if (!accepted) - accepted = std::make_shared>(); - - // Only entries the user approved (Yes / All) may replace their destination. The others did not exist when - // probed, one that appears before the transfer runs fails instead of being replaced. - auto flushAccepted = [this, &accepted]() - { - if (accepted->empty()) - return; - // Single-file fast path: a one-entry flush skips the bulk machinery - // so the operation appears as a regular transfer card (progress bar - // + resumable) rather than a bulk aggregate. Directories stay on - // the bulk path because they need the Scan+Bulk pair. - if (accepted->size() == 1 && !accepted->front().isDirectory) - { - enqueueSingleUpload( - accepted->front(), - /*allowOverwrite=*/accepted->front().mayOverwrite, - /*insertRefresh=*/true - ); - accepted->clear(); - return; - } - operationQueue_->enqueueBulkUpload( - std::move(*accepted), - /*allowOverwrite*/ false, - /*insertRefresh*/ true, - SharedData::OperationMode::Queued, - /*onEachComplete*/ {}, + // One file shows as a single transfer card with progress and resume, directories need the scan of a bulk. + if (accepted.size() == 1 && !accepted.front().isDirectory) + return enqueueSingleUpload(accepted.front(), accepted.front().mayOverwrite, true); + operationQueue_->enqueueBulkUpload( + std::move(accepted), + /*allowOverwrite*/ false, + /*insertRefresh*/ true, + SharedData::OperationMode::Queued, + /*onEachComplete*/ {}, + lifetime_.guarded( [this](bool success, std::string const& info) { - if (!success) - { - Log::error("Bulk upload failed: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "uploadFailed"), - .text = info, - .buttons = ConfirmDialog::Button::Ok, - }); - } + if (success) + return; + Log::error("Bulk upload failed: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("localSideModel", "uploadFailed"), + .text = info, + .buttons = ConfirmDialog::Button::Ok, + }); } - ); - }; - - if (index == candidates.size()) - { - flushAccepted(); - return; - } - - if (overwriteAlways) - { - for (; index < candidates.size(); ++index) - accepted->push_back(approvedForOverwrite(candidates[index])); - flushAccepted(); - return; - } - - while (index < candidates.size()) - { - auto const& candidate = candidates[index]; - // Without an answer the destination counts as existing, so the user is asked. - const bool exists = !existsResults || index >= existsResults->size() || (*existsResults)[index]; - - if (!exists) - { - accepted->push_back(candidate); - ++index; - continue; - } - if (exists && overwriteNever) - { - ++index; - continue; - } - break; - } - - if (index >= candidates.size()) - { - flushAccepted(); - return; - } - - auto const& candidate = candidates[index]; - Log::info("Uploading '{}' to '{}'.", candidate.source.toUtf8(), candidate.destination.value()); - - confirmDialog_->open( - {.styleVariant = ScriptNuiComponents::StyleVariant::Primary, - .headerText = language->get("localSideModel", "fileAlreadyExistsOverwriteHeader"), - .text = language->get("localSideModel", "fileAlreadyExistsOverwrite"), - .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No | ConfirmDialog::Button::All | - ConfirmDialog::Button::None | ConfirmDialog::Button::Cancel, - .focusButton = ConfirmDialog::Button::No, - .listItems = {{.text = candidate.destination.value(), .description = language->get("fileExplorer", "fileAlreadyExists")}}, - .onClose = [this, - candidates = std::move(candidates), - existsResults, - index, - overwriteNever, - overwriteAlways, - accepted](std::optional button) mutable - { - if (button && *button == ConfirmDialog::Button::Yes) - { - accepted->push_back(approvedForOverwrite(candidates[index])); - uploadItemsConfirmed( - std::move(candidates), - std::move(existsResults), - index + 1, - overwriteNever, - overwriteAlways, - std::move(accepted) - ); - } - else if (button && *button == ConfirmDialog::Button::No) - { - Log::info("Skipping upload of existing file: {}", candidates[index].destination.value()); - uploadItemsConfirmed( - std::move(candidates), - std::move(existsResults), - index + 1, - overwriteNever, - overwriteAlways, - std::move(accepted) - ); - } - else if (button && *button == ConfirmDialog::Button::All) - { - Log::info("Overwriting all existing files from now on."); - accepted->push_back(approvedForOverwrite(candidates[index])); - uploadItemsConfirmed( - std::move(candidates), - 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(candidates), - std::move(existsResults), - index + 1, - /*overwriteNever*/ true, - overwriteAlways, - std::move(accepted) - ); - } - else - { - const auto terminalIndex = candidates.size(); - uploadItemsConfirmed( - std::move(candidates), - std::move(existsResults), - terminalIndex, - overwriteNever, - overwriteAlways, - std::move(accepted) - ); - } - }} + ) ); } diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 0cbaa2cc..1cec29dd 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -683,225 +683,34 @@ void RemoteSideModel::enqueueSingleDownload( ); } -namespace -{ - /** - * @brief @p entry with the user's consent to replace its existing destination. - */ - template - EntryT approvedForOverwrite(EntryT entry) - { - entry.mayOverwrite = true; - return entry; - } -} - -void RemoteSideModel::downloadItemsConfirmed( - std::vector candidates, - std::shared_ptr> existsResults, - std::size_t index, - bool overwriteNever, - bool overwriteAlways, - std::shared_ptr> accepted -) +void RemoteSideModel::enqueueDownloads(std::vector accepted) { - // Lazily allocate the accumulator on the first (top-level) call. All - // recursive calls below thread the same shared_ptr so per-file - // confirmations land in one batch that's flushed as a single bulk RPC at - // the terminal recursion. - if (!accepted) - accepted = std::make_shared>(); - - // Only entries the user approved (Yes / All) may replace their destination. The others did not exist when - // probed, one that appears before the transfer runs fails instead of being replaced. - auto flushAccepted = [this, &accepted]() - { - if (accepted->empty()) - return; - // Single-file fast path: a one-entry flush skips the bulk machinery - // so the operation appears as a regular transfer card (progress bar - // + resumable) rather than a bulk aggregate. Directories stay on - // the bulk path because they need the Scan+Bulk pair. - if (accepted->size() == 1 && !accepted->front().isDirectory) - { - enqueueSingleDownload( - accepted->front(), - /*allowOverwrite=*/accepted->front().mayOverwrite, - /*insertRefresh=*/true - ); - accepted->clear(); - return; - } - // Terminal: flush the accumulated batch in a single RPC. The backend - // skips per-file lstats for file entries (sizes are trusted) and - // performs all queueing inside one strand dispatch — replacing N - // RPCs with one. - operationQueue_->enqueueBulkDownload( - std::move(*accepted), - /*allowOverwrite*/ false, - /*insertRefresh*/ true, - SharedData::OperationMode::Queued, - /*onEachComplete*/ {}, + // One file shows as a single transfer card with progress and resume, directories need the scan of a bulk. + if (accepted.size() == 1 && !accepted.front().isDirectory) + return enqueueSingleDownload(accepted.front(), accepted.front().mayOverwrite, true); + operationQueue_->enqueueBulkDownload( + std::move(accepted), + /*allowOverwrite*/ false, + /*insertRefresh*/ true, + SharedData::OperationMode::Queued, + /*onEachComplete*/ {}, + lifetime_.guarded( [this](bool success, std::string const& info) { - if (!success) - { - Log::error("Bulk download failed: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("remoteSideModel", "downloadFailed"), - .text = fmt::format( - fmt::runtime(language->get("remoteSideModel", "failedToCreateDownloadOperation")), info - ), - .buttons = ConfirmDialog::Button::Ok, - }); - } + if (success) + return; + Log::error("Bulk download failed: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("remoteSideModel", "downloadFailed"), + .text = fmt::format( + fmt::runtime(language->get("remoteSideModel", "failedToCreateDownloadOperation")), info + ), + .buttons = ConfirmDialog::Button::Ok, + }); } - ); - }; - - if (index == candidates.size()) - { - flushAccepted(); - return; - } - - if (overwriteAlways) - { - for (; index < candidates.size(); ++index) - accepted->push_back(approvedForOverwrite(candidates[index])); - flushAccepted(); - return; - } - - // Iterate synchronously until we hit either end-of-list or a conflict - // that requires an async confirmation dialog. Recursing per-item would - // blow WASM's ~64KB stack at ~175 deep for a 1000-file batch, since each - // frame holds a moved copy of the candidates. - while (index < candidates.size()) - { - auto const& candidate = candidates[index]; - // existsResults was filled by a single batched RpcFilesystem::existsBatch - // call before the loop started, so this adds zero RPC round-trips. - // Without an answer the destination counts as existing, so the user is asked. - const bool exists = !existsResults || index >= existsResults->size() || (*existsResults)[index]; - - if (!exists) - { - accepted->push_back(candidate); - ++index; - continue; - } - if (exists && overwriteNever) - { - ++index; - continue; - } - - // Conflict that actually needs a user decision — break out to the - // async prompt below. The dialog's onClose callback resumes the - // iteration at index+1 via one non-tail recursion, keeping the - // number of stacked frames bounded by the number of conflicts (not - // the number of files). - break; - } - - if (index >= candidates.size()) - { - // All items processed without needing further prompts — flush. - flushAccepted(); - return; - } - - auto const& candidate = candidates[index]; - Log::info("Downloading '{}' to '{}'.", candidate.source.value(), candidate.destination.toUtf8()); - - { - Log::info("File already exists: {}", candidate.destination.toUtf8()); - confirmDialog_->open( - {.styleVariant = ScriptNuiComponents::StyleVariant::Primary, - .headerText = language->get("remoteSideModel", "fileAlreadyExistsOverwrite"), - .text = language->get("remoteSideModel", "allowOverwritingFile"), - .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No | ConfirmDialog::Button::All | - ConfirmDialog::Button::None | ConfirmDialog::Button::Cancel, - .focusButton = ConfirmDialog::Button::No, - .listItems = {{.text = candidate.destination.toUtf8(), .description = language->get("fileExplorer", "fileAlreadyExists")}}, - .onClose = [this, - candidates = std::move(candidates), - existsResults, - index, - overwriteNever, - overwriteAlways, - accepted](std::optional button) mutable - { - if (button && button == ConfirmDialog::Button::Yes) - { - accepted->push_back(approvedForOverwrite(candidates[index])); - downloadItemsConfirmed( - std::move(candidates), - std::move(existsResults), - index + 1, - overwriteNever, - overwriteAlways, - std::move(accepted) - ); - } - else if (button && button == ConfirmDialog::Button::No) - { - Log::info( - "Skipping download of existing file: {}", candidates[index].destination.toUtf8() - ); - downloadItemsConfirmed( - std::move(candidates), - std::move(existsResults), - index + 1, - overwriteNever, - overwriteAlways, - std::move(accepted) - ); - } - else if (button && button == ConfirmDialog::Button::All) - { - Log::info("Overwriting all existing files from now on."); - accepted->push_back(approvedForOverwrite(candidates[index])); - downloadItemsConfirmed( - std::move(candidates), - 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(candidates), - std::move(existsResults), - index + 1, - /*overwriteNever*/ true, - overwriteAlways, - std::move(accepted) - ); - } - else - { - // Cancel — flush whatever the user has already - // accepted so far and stop iterating. - const auto terminalIndex = candidates.size(); - downloadItemsConfirmed( - std::move(candidates), - std::move(existsResults), - terminalIndex, - overwriteNever, - overwriteAlways, - std::move(accepted) - ); - } - }} - ); - } + ) + ); } void RemoteSideModel::onDropExternal( @@ -942,132 +751,41 @@ void RemoteSideModel::onTransfer( showPathRefused(language->get("remoteSideModel", "downloadFailed"), error); }; const auto destinationDirectory = localModel_->currentLocalPath().and_then( - [&subDir](Paths::LocalPath const& directory) -> std::expected + [&subDir](Paths::LocalPath const& directory) { - if (!subDir) - return directory; - return Paths::LocalPathSegment::make(*subDir).transform( - [&directory](Paths::LocalPathSegment const& segment) - { - return directory / segment; - } - ); + return FileExplorer::targetDirectoryOf(directory, subDir); } ); if (!destinationDirectory) return refuse(destinationDirectory.error()); + // A name this platform cannot hold is refused before anything is written. + auto candidates = transferCandidates( + items, + [this](NuiFileExplorer::Item const& item) + { + return remotePathOf(item); + }, + *destinationDirectory + ); + if (!candidates) + return refuse(candidates.error()); - std::vector candidates; - candidates.reserve(items.size()); - for (auto const& item : items) - { - // A name this platform cannot hold is refused before anything is written. - auto source = remotePathOf(item); - auto destination = source.and_then( - [&destinationDirectory](Paths::RemotePath const& path) - { - return Paths::LocalPathSegment::make(*path.filename()) - .transform( - [&destinationDirectory](Paths::LocalPathSegment const& segment) - { - return *destinationDirectory / segment; - } - ); - } - ); - if (!destination) - return refuse(destination.error()); - candidates.push_back( - SharedData::BulkDownloadEntry{ - .source = *std::move(source), - .destination = *std::move(destination), - .sizeBytes = item.size, - .isDirectory = item.isDirectory(), - .mtime = item.mtime, - .mtimeNsec = item.mtimeNsec, - } - ); - } - - const auto itemsSize = candidates.size(); - std::string confirmText; - if (itemsSize > 1) - { - confirmText = fmt::format( - fmt::runtime(language->get("remoteSideModel", "downloadConfirmIfMultiple")), - itemsSize, - destinationDirectory->toUtf8() - ); - } - else - { - confirmText = fmt::format( - fmt::runtime(language->get("remoteSideModel", "downloadConfirmIfSingle")), - candidates.front().source.value(), - destinationDirectory->toUtf8() - ); - } - - std::vector listItems; - for (const auto& candidate : candidates) - listItems.push_back({candidate.source.value(), ""}); - - confirmDialog_->open( - {.styleVariant = ScriptNuiComponents::StyleVariant::Primary, + confirmTransfer( + *std::move(candidates), + { .headerText = language->get("remoteSideModel", "downloadItemsQuestion"), - .text = confirmText, - .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, - .focusButton = ConfirmDialog::Button::Yes, - .listItems = listItems, - .onClose = [this, candidates = std::move(candidates)](std::optional button) mutable + .singleText = language->get("remoteSideModel", "downloadConfirmIfSingle"), + .multipleText = language->get("remoteSideModel", "downloadConfirmIfMultiple"), + .destinationDirectory = destinationDirectory->toUtf8(), + .overwriteHeaderText = language->get("remoteSideModel", "fileAlreadyExistsOverwrite"), + .overwriteText = language->get("remoteSideModel", "allowOverwritingFile"), + }, + lifetime_.guarded( + [this](std::vector accepted) { - if (!button || *button != ConfirmDialog::Button::Yes) - { - Log::info("Download items cancelled"); - return; - } - - Log::info("Downloading items."); - - // Batched existence probe: one RPC for the whole batch instead of N. The recursion below then walks - // the cached result vector with zero further round-trips. - std::vector destinations; - destinations.reserve(candidates.size()); - for (auto const& candidate : candidates) - destinations.push_back(candidate.destination.toUtf8()); - - Nui::RpcClient::callWithBackChannel( - "RpcFilesystem::existsBatch", - [this, candidates = std::move(candidates)](Nui::val response) mutable - { - // An unknown destination is never taken for absent, the batch overwrites what it accepts. - if (!response.hasOwnProperty("success") || !response["success"].as() || - !response.hasOwnProperty("exists")) - { - return showExistenceCheckFailed( - response.hasOwnProperty("error") ? language->translate(response["error"].as()) - : std::string{"RpcFilesystem::existsBatch"} - ); - } - - auto existsResults = std::make_shared>(); - auto arr = response["exists"]; - const auto length = arr["length"].as(); - existsResults->reserve(static_cast(length)); - for (long long idx = 0; idx < length; ++idx) - existsResults->push_back(arr[static_cast(idx)].as()); - if (existsResults->size() != candidates.size()) - { - return showExistenceCheckFailed( - fmt::format("{} of {} answers", existsResults->size(), candidates.size()) - ); - } - - downloadItemsConfirmed(std::move(candidates), std::move(existsResults)); - }, - destinations - ); - }} + enqueueDownloads(std::move(accepted)); + } + ) ); } diff --git a/frontend/source/frontend/file_explorer/side_model.cpp b/frontend/source/frontend/file_explorer/side_model.cpp index f464e03c..41bf277c 100644 --- a/frontend/source/frontend/file_explorer/side_model.cpp +++ b/frontend/source/frontend/file_explorer/side_model.cpp @@ -77,6 +77,19 @@ void SideModel::confirmReplaceOnRename(std::string const& name, std::function button) +{ + if (button == ConfirmDialog::Button::Yes) + return FileExplorer::OverwriteAnswer::Yes; + if (button == ConfirmDialog::Button::No) + return FileExplorer::OverwriteAnswer::No; + if (button == ConfirmDialog::Button::All) + return FileExplorer::OverwriteAnswer::All; + if (button == ConfirmDialog::Button::None) + return FileExplorer::OverwriteAnswer::None; + return FileExplorer::OverwriteAnswer::Cancel; +} + bool SideModel::isComplete() const { return operationQueue_ != nullptr && fileEngine_ != nullptr; diff --git a/frontend/source/frontend/terminal/file_engine.cpp b/frontend/source/frontend/terminal/file_engine.cpp index a4776d30..ef44ec92 100644 --- a/frontend/source/frontend/terminal/file_engine.cpp +++ b/frontend/source/frontend/terminal/file_engine.cpp @@ -1,13 +1,55 @@ #include #include +#include #include #include #include #include +#include + +#include +#include +#include #include +namespace +{ + template + std::vector pathTexts(std::vector const& paths) + { + std::vector texts; + texts.reserve(paths.size()); + std::ranges::transform( + paths, + std::back_inserter(texts), + [](PathT const& path) + { + return FileExplorer::pathText(path); + } + ); + return texts; + } + + /** + * @brief Hands the exists flags of an existsBatch reply to @p onComplete, or no flags and the error. + */ + void replyExistsFlags( + Nui::val const& reply, + std::string_view function, + std::function exists, std::string const& info)> const& onComplete + ) + { + if (reply.hasOwnProperty("success") && reply["success"].as() && reply.hasOwnProperty("exists")) + return onComplete(emscripten::vecFromJSArray(reply["exists"]), "Success"); + const auto error = reply.hasOwnProperty("error") ? reply["error"].as() + : fmt::format("Malformed {} response", function); + Log::error("(Frontend) {} failed: {}", function, error); + onComplete({}, error); + } +} + struct FileEngine::Implementation { bool wasDisposed = false; @@ -1159,15 +1201,10 @@ void FileEngine::existsBatchRemote( { Log::info("Requesting existsBatch for {} remote paths", paths.size()); - std::vector pathStrings; - pathStrings.reserve(paths.size()); - for (auto const& path : paths) - pathStrings.push_back(path.value()); - lazyOpen( - [this, - pathStrings = std::move(pathStrings), - onComplete = std::move(onComplete)](auto const& channelId, std::string const& info) mutable + [this, pathStrings = pathTexts(paths), onComplete = std::move(onComplete)]( + auto const& channelId, std::string const& info + ) mutable { if (!channelId) { @@ -1180,24 +1217,7 @@ void FileEngine::existsBatchRemote( fmt::format("Session::{}::sftp::existsBatch", impl_->engine->sshSessionId().value()), [onComplete = std::move(onComplete)](Nui::val val) { - if (val.hasOwnProperty("error")) - { - Log::error("(Frontend) sftp::existsBatch failed: {}", val["error"].as()); - onComplete({}, val["error"].as()); - return; - } - if (!val.hasOwnProperty("success") || !val["success"].as() || !val.hasOwnProperty("exists")) - { - onComplete({}, "Malformed sftp::existsBatch response"); - return; - } - auto arr = val["exists"]; - const auto length = arr["length"].as(); - std::vector results; - results.reserve(static_cast(length)); - for (long long idx = 0; idx < length; ++idx) - results.push_back(arr[static_cast(idx)].as()); - onComplete(std::move(results), "Success"); + replyExistsFlags(val, "sftp::existsBatch", onComplete); }, channelId.value().value(), pathStrings @@ -1206,6 +1226,23 @@ void FileEngine::existsBatchRemote( ); } +void FileEngine::existsBatchLocal( + std::vector const& paths, + std::function exists, std::string const& info)> onComplete +) +{ + Log::info("Requesting existsBatch for {} local paths", paths.size()); + + Nui::RpcClient::callWithBackChannel( + "RpcFilesystem::existsBatch", + [onComplete = std::move(onComplete)](Nui::val val) + { + replyExistsFlags(val, "RpcFilesystem::existsBatch", onComplete); + }, + pathTexts(paths) + ); +} + void FileEngine::stat( std::filesystem::path const& path, std::function< From a074244762b5a1ec90dd2b2652409c54fde8ca67 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:32:20 +0200 Subject: [PATCH 03/62] Renaming in the explorer is one flow in SideModel for both sides Renaming in the explorer is one flow in SideModel for both sides: the local side renames through FileEngine::renameLocal like the remote side through FileEngine::rename, both ask before replacing an existing item and show failures with the texts of their own side, the local side no longer with the remote side's texts --- .../file_explorer/local_side_model.hpp | 7 -- .../file_explorer/remote_side_model.hpp | 7 -- .../include/frontend/terminal/file_engine.hpp | 11 ++ .../file_explorer/local_side_model.cpp | 108 +++--------------- .../file_explorer/remote_side_model.cpp | 73 +----------- .../source/frontend/terminal/file_engine.cpp | 28 +++++ static/assets/languages/de_DE.yaml | 5 + static/assets/languages/en_US.yaml | 5 + 8 files changed, 63 insertions(+), 181 deletions(-) diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index be70d6af..6f144afa 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -126,13 +126,6 @@ class LocalSideModel ) override; private: - void enqueueRefresh(bool otherModel); - - /** - * @brief Renames @p from to @p to, asks before replacing an existing item and retries with @p overwrite. - */ - void renameItem(Paths::LocalPath const& from, Paths::LocalPath const& to, bool overwrite); - /** * @brief Where @p item is: a listed item below the directory it was listed from, a dropped one at its own * absolute path. A listed name that is no single path segment, like "..", names no item. diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index 729b1656..da081c05 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -118,13 +118,6 @@ class RemoteSideModel ) override; private: - void enqueueRefresh(bool otherModel); - - /** - * @brief Renames @p from to @p to, asks before replacing an existing item and retries with @p overwrite. - */ - void renameItem(Paths::RemotePath const& from, Paths::RemotePath const& to, bool overwrite); - /** * @brief Where @p item is on the server, below the directory it was listed from. A listed name that is no * single path segment, like "..", names no item. diff --git a/frontend/include/frontend/terminal/file_engine.hpp b/frontend/include/frontend/terminal/file_engine.hpp index 3b340b08..9d711f79 100644 --- a/frontend/include/frontend/terminal/file_engine.hpp +++ b/frontend/include/frontend/terminal/file_engine.hpp @@ -264,6 +264,17 @@ class FileEngine std::function onComplete ); + /** + * @brief Renames on this computer, needs no connection. Fails with "backend.rename.targetExists" when + * @p newPath exists and @p overwrite is false. + */ + static void renameLocal( + Paths::LocalPath const& oldPath, + Paths::LocalPath const& newPath, + bool overwrite, + std::function onComplete + ); + void addRename( Paths::RemotePath const& sourcePath, Paths::RemotePath const& destinationPath, diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index 3e5c8529..e72b80cf 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -705,95 +705,13 @@ void LocalSideModel::onDropExternal( onTransfer(items, subDir); } -void LocalSideModel::renameItem(Paths::LocalPath const& from, Paths::LocalPath const& to, bool overwrite) -{ - Nui::val args = Nui::val::object(); - args.set("oldPath", from.toUtf8()); - args.set("newPath", to.toUtf8()); - args.set("overwrite", overwrite); - - Nui::RpcClient::callWithBackChannel( - "RpcFilesystem::rename", - [this, from, to, overwrite](Nui::val val) - { - if (!val.hasOwnProperty("success")) - { - Log::error("Invalid response from RpcFilesystem::rename"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "renameFailed"), - .text = language->get("localSideModel", "invalidResponseFromBackend"), - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - - if (!val["success"].as()) - { - const auto errorKey = val["error"].as(); - if (errorKey == "backend.rename.targetExists" && !overwrite) - { - confirmReplaceOnRename( - std::string{to.filename()->view()}, - [this, from, to]() - { - renameItem(from, to, true); - } - ); - return; - } - const auto error = language->translate(errorKey); - Log::error("Failed to rename item: {}", error); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "renameFailed"), - .text = error, - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - - Log::info("Item renamed successfully"); - onRefresh(); - }, - args - ); -} - void LocalSideModel::onRename(NuiFileExplorer::Item const& item) { Log::info("Rename item requested: {}", item.name); - // The item's own path: the explorer may already show another directory. - auto from = localPathOf(item); - if (!from) - return showPathRefused(language->get("localSideModel", "renameFailed"), from.error()); - - inputDialog_->open({ - .whatFor = language->get("remoteSideModel", "rename"), - .prompt = fmt::format(fmt::runtime(language->get("remoteSideModel", "renamePrompt")), item.name), - .headerText = - fmt::format(fmt::runtime(language->get("remoteSideModel", "renameWithItem")), item.name), - .initialValue = item.name, - .isPassword = false, - .onConfirm = [this, from = *std::move(from), item](std::optional const& name) - { - if (!name || name->empty()) - return; - if (*name == item.name) - return; - if (!isValidItemName(*name)) - return showInvalidItemName(*name); - const auto segment = Paths::LocalPathSegment::make(*name); - const auto directory = from.parent(); - if (!segment || !directory) - return showInvalidItemName(*name); - - Log::info("Renaming item to: {}", *name); - renameItem(from, *directory / *segment, false); - }, - }); + promptRename(item, localPathOf(item)); } + void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) { // listFiles only carries the lean field set; fetch the full entry (atime, birthtime, acl, ...) @@ -818,17 +736,17 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) filePropertyDialog_->open(fallback, displayPath); }; - if (!val.hasOwnProperty("success") || !val["success"].as()) - { - Log::warn("RpcFilesystem::properties failed; opening dialog with cached entry"); - useFallback(); - return; - } - if (!val.hasOwnProperty("entry") || val["entry"].isNull() || val["entry"].isUndefined()) - { - useFallback(); - return; - } + if (!val.hasOwnProperty("success") || !val["success"].as()) + { + Log::warn("RpcFilesystem::properties failed; opening dialog with cached entry"); + useFallback(); + return; + } + if (!val.hasOwnProperty("entry") || val["entry"].isNull() || val["entry"].isUndefined()) + { + useFallback(); + return; + } const auto extractFull = [](Nui::val const& source) -> SharedData::DirectoryEntry { diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 1cec29dd..e9899c59 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -917,84 +917,13 @@ void RemoteSideModel::onTransferAsArchive(std::vector con }); } -void RemoteSideModel::renameItem(Paths::RemotePath const& from, Paths::RemotePath const& to, bool overwrite) -{ - fileEngine_->rename( - from, - to, - overwrite, - [this, from, to, overwrite](bool success, std::string const& info) - { - if (!success) - { - if (info == "backend.rename.targetExists" && !overwrite) - { - confirmReplaceOnRename( - std::string{to.filename()->view()}, - [this, from, to]() - { - renameItem(from, to, true); - } - ); - return; - } - Log::error("Failed to rename item '{}' to '{}': {}", from.value(), to.value(), info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = "", - .text = fmt::format( - fmt::runtime(language->get("remoteSideModel", "failedToRenameItem")), - from.value(), - to.value(), - language->translate(info) - ), - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - Log::info("Renamed item '{}' to '{}'", from.value(), to.value()); - onRefresh(); - } - ); -} - void RemoteSideModel::onRename(NuiFileExplorer::Item const& item) { CHECK_COMPLETE(); Log::info("Rename item requested: {}", item.name); - // The item's own path: the explorer may already show another directory. - auto from = remotePathOf(item); - if (!from) - return showPathRefused(language->get("remoteSideModel", "rename"), from.error()); - - inputDialog_->open({ - .whatFor = language->get("remoteSideModel", "rename"), - .prompt = - fmt::format(fmt::runtime(language->get("remoteSideModel", "renamePrompt")), item.name), - .headerText = fmt::format( - fmt::runtime(language->get("remoteSideModel", "renameWithItem")), item.name - ), - .initialValue = item.name, - .isPassword = false, - .onConfirm = [this, from = *std::move(from), item](std::optional const& name) - { - if (!name || name->empty()) - return; - if (*name == item.name) - return; - if (!isValidItemName(*name)) - return showInvalidItemName(*name); - const auto segment = Paths::PathSegment::make(*name); - const auto directory = from.parent(); - if (!segment || !directory) - return showInvalidItemName(*name); - - Log::info("Renaming item to: {}", *name); - renameItem(from, *directory / *segment, false); - }, - }); + promptRename(item, remotePathOf(item)); } void RemoteSideModel::onProperties(NuiFileExplorer::Item const& item) diff --git a/frontend/source/frontend/terminal/file_engine.cpp b/frontend/source/frontend/terminal/file_engine.cpp index ef44ec92..52c2949c 100644 --- a/frontend/source/frontend/terminal/file_engine.cpp +++ b/frontend/source/frontend/terminal/file_engine.cpp @@ -1091,6 +1091,34 @@ void FileEngine::rename( ); } +void FileEngine::renameLocal( + Paths::LocalPath const& oldPath, + Paths::LocalPath const& newPath, + bool overwrite, + std::function onComplete +) +{ + Log::info("Requesting to rename local item: {} -> {}", oldPath.toUtf8(), newPath.toUtf8()); + + Nui::val args = Nui::val::object(); + args.set("oldPath", oldPath.toUtf8()); + args.set("newPath", newPath.toUtf8()); + args.set("overwrite", overwrite); + + Nui::RpcClient::callWithBackChannel( + "RpcFilesystem::rename", + [onComplete = std::move(onComplete)](Nui::val val) + { + if (!val.hasOwnProperty("success")) + return onComplete(false, "Invalid response from RpcFilesystem::rename"); + if (!val["success"].as()) + return onComplete(false, val["error"].as()); + onComplete(true, "Success"); + }, + args + ); +} + void FileEngine::addRename( Paths::RemotePath const& sourcePath, Paths::RemotePath const& destinationPath, diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index eaf0e33c..571cbd4a 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -107,6 +107,7 @@ remoteSideModel: renamePrompt: "Geben Sie den neuen Namen für {} ein" renameWithItem: "{} umbenennen" failedToRenameItem: "Fehler beim Umbenennen von '{}' in '{}': {}" + renameFailed: "Umbenennen fehlgeschlagen" archiveDownloadFailed: "Archiv-Download fehlgeschlagen" downloadItemsQuestion: "Elemente herunterladen?" localSideModel: @@ -117,6 +118,10 @@ localSideModel: invalidResponseFromBackend: "Ungültige Antwort vom Backend" fileGridError: "Dateiansicht Fehler (Lokale Seite)" renameFailed: "Umbenennen fehlgeschlagen" + rename: "Umbenennen" + renamePrompt: "Geben Sie den neuen Namen für {} ein" + renameWithItem: "{} umbenennen" + failedToRenameItem: "Fehler beim Umbenennen von '{}' in '{}': {}" uploadFailed: "Hochladen fehlgeschlagen" uploadSingleConfirmText: "Sind Sie sicher, dass Sie dieses Element '{}' nach {} hochladen möchten?" uploadMultipleConfirmText: "Sind Sie sicher, dass Sie die folgenden {} Elemente nach {} hochladen möchten?" diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index 8af1bdb4..59d7a35a 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -205,6 +205,7 @@ remoteSideModel: renamePrompt: "Enter the new name for {}" renameWithItem: "Rename {}" failedToRenameItem: "Failed to rename item '{}' to '{}': {}" + renameFailed: "Rename Failed" archiveDownloadFailed: "Archive Download Failed" downloadItemsQuestion: "Download Items?" localSideModel: @@ -215,6 +216,10 @@ localSideModel: invalidResponseFromBackend: "Invalid response from backend" fileGridError: "File Grid Error (Local Side)" renameFailed: "Rename Failed" + rename: "Rename" + renamePrompt: "Enter the new name for {}" + renameWithItem: "Rename {}" + failedToRenameItem: "Failed to rename item '{}' to '{}': {}" uploadFailed: "Upload Failed" uploadSingleConfirmText: "Are you sure you want to upload this item '{}' to {}?" uploadMultipleConfirmText: "Are you sure you want to upload the following {} items to {}?" From ebb97208a805dd61aec095b283c03a686e76dde0 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:36:14 +0200 Subject: [PATCH 04/62] The side models share favorites, context menus and navigation The side models share favorites, their context menu entries and directory navigation in SideModel, both go through the checked item paths so a name that is no single path segment is refused instead of navigated to or added; local listings and properties read entries with one directoryEntryOf, backendCompressionCode lives next to the archive codecs, and opening a remote file with or without watching is one function --- .../dialog/archive_transfer_dialog.hpp | 6 + .../file_explorer/local_side_model.hpp | 23 +- .../file_explorer/remote_side_model.hpp | 30 +- .../frontend/file_explorer/side_model.hpp | 56 ++- .../dialog/archive_transfer_dialog.cpp | 18 + .../file_explorer/local_side_model.cpp | 407 +++++------------- .../file_explorer/remote_side_model.cpp | 292 +++---------- .../frontend/file_explorer/side_model.cpp | 122 ++++++ 8 files changed, 357 insertions(+), 597 deletions(-) diff --git a/frontend/include/frontend/dialog/archive_transfer_dialog.hpp b/frontend/include/frontend/dialog/archive_transfer_dialog.hpp index c591ed5a..aab32070 100644 --- a/frontend/include/frontend/dialog/archive_transfer_dialog.hpp +++ b/frontend/include/frontend/dialog/archive_transfer_dialog.hpp @@ -36,6 +36,12 @@ std::string archiveCodecExtension(ArchiveCodec codec); */ ArchiveCodec archiveCodecFromExtension(std::string const& extension); +/** + * @brief The value of TarArchive::Compression the backend expects for @p codec: None=1, Gzip=2, Bzip2=3, Zstd=4, + * Xz=5 (Auto=0 is never sent). + */ +int backendCompressionCode(ArchiveCodec codec); + /** * @brief Structured result handed to the caller when the dialog is confirmed. * diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 6f144afa..725fa688 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -11,7 +11,6 @@ #include #include -#include #include #include @@ -30,7 +29,6 @@ class LocalSideModel : public SideModel , public NuiFileExplorer::IPlacesProvider , public NuiFileExplorer::IDrivesProvider - , public NuiFileExplorer::IFavoritesProvider { public: LocalSideModel( @@ -41,14 +39,6 @@ class LocalSideModel ArchiveTransferDialog* archiveTransferDialog ); - /** - * @brief Sets the callback invoked whenever the favorites list changes. The callback - * receives the updated list as strings so the caller can persist it. - * - * @param callback Invoked on every add/remove. - */ - void setOnFavoritesChanged(std::function)> callback); - // --- IPlacesProvider --- NuiFileExplorer::IPlacesProvider* placesProvider() override { @@ -62,15 +52,6 @@ class LocalSideModel NuiFileExplorer::IDrivesProvider* drivesProvider() override; void requestDrives(std::function)> callback) override; - // --- IFavoritesProvider --- - NuiFileExplorer::IFavoritesProvider* favoritesProvider() override - { - return this; - } - std::shared_ptr>> favorites() const override; - void addFavorite(std::filesystem::path const& path) override; - void removeFavorite(std::filesystem::path const& path) override; - void onActivateItem(NuiFileExplorer::Item const& item) override; void onNewItem(NuiFileExplorer::Item::Type type) override; void onDelete(std::vector const& items) override; @@ -132,6 +113,8 @@ class LocalSideModel */ std::expected localPathOf(NuiFileExplorer::Item const& item) const; + std::expected itemPathText(NuiFileExplorer::Item const& item) const override; + /** * @brief Queues the uploads the user accepted, a single file as a transfer of its own. */ @@ -160,7 +143,5 @@ class LocalSideModel RemoteSideModel* remoteModel_{nullptr}; NuiFileExplorer::PathSuggestionCache pathSuggestionCache_; - std::shared_ptr>> favorites_; - std::function)> onFavoritesChanged_; SharedData::OpenerCapabilities openerCaps_{}; }; \ No newline at end of file diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index da081c05..78da0bca 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -9,7 +9,6 @@ #include #include -#include #include #include @@ -30,7 +29,6 @@ class LocalSideModel; class RemoteSideModel : public SideModel , public NuiFileExplorer::IPlacesProvider - , public NuiFileExplorer::IFavoritesProvider { public: RemoteSideModel( @@ -42,14 +40,6 @@ class RemoteSideModel ArchiveTransferDialog* archiveTransferDialog ); - /** - * @brief Sets the callback invoked whenever the favorites list changes. The callback - * receives the updated list as strings so the caller can persist it. - * - * @param callback Invoked on every add/remove. - */ - void setOnFavoritesChanged(std::function)> callback); - // --- IPlacesProvider --- NuiFileExplorer::IPlacesProvider* placesProvider() override { @@ -65,15 +55,6 @@ class RemoteSideModel */ void setRemoteHome(std::filesystem::path home, bool readUserDirectories); - // --- IFavoritesProvider --- - NuiFileExplorer::IFavoritesProvider* favoritesProvider() override - { - return this; - } - std::shared_ptr>> favorites() const override; - void addFavorite(std::filesystem::path const& path) override; - void removeFavorite(std::filesystem::path const& path) override; - void onActivateItem(NuiFileExplorer::Item const& item) override; void onNewItem(NuiFileExplorer::Item::Type type) override; void onDelete(std::vector const& items) override; @@ -124,6 +105,8 @@ class RemoteSideModel */ std::expected remotePathOf(NuiFileExplorer::Item const& item) const; + std::expected itemPathText(NuiFileExplorer::Item const& item) const override; + /** * @brief @p name directly below the directory the shown items were listed from. */ @@ -147,8 +130,11 @@ class RemoteSideModel enqueueDeletes(std::vector nonEmpties, std::vector filesAndEmptyDirs); void continueDeletion(); - void onDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith); - void onWatchDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith); + /** + * @brief Downloads @p item into a file tracking instance and opens it, with @p watch also uploads it again when + * it changes. + */ + void onDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith, bool watch); void onFileTrackingInstanceCreated( NuiFileExplorer::Item const& item, bool openWith, @@ -196,6 +182,4 @@ class RemoteSideModel LocalSideModel* localModel_{nullptr}; FileTrackingPanel* fileTracking_{nullptr}; NuiFileExplorer::PathSuggestionCache pathSuggestionCache_; - std::shared_ptr>> favorites_; - std::function)> onFavoritesChanged_; }; \ No newline at end of file diff --git a/frontend/include/frontend/file_explorer/side_model.hpp b/frontend/include/frontend/file_explorer/side_model.hpp index 3c69b7d2..604e233c 100644 --- a/frontend/include/frontend/file_explorer/side_model.hpp +++ b/frontend/include/frontend/file_explorer/side_model.hpp @@ -15,6 +15,8 @@ #include #include +#include +#include #include #include @@ -54,7 +56,9 @@ #define CHECK_COMPLETE() CHECK_COMPLETE_RET() // Incomplete class, provides base functionality -class SideModel : public NuiFileExplorer::ISideModel +class SideModel + : public NuiFileExplorer::ISideModel + , public NuiFileExplorer::IFavoritesProvider { public: SideModel( @@ -125,10 +129,56 @@ class SideModel : public NuiFileExplorer::ISideModel return {}; } + /** + * @brief Sets the callback invoked whenever the favorites list changes. The callback + * receives the updated list as strings so the caller can persist it. + * + * @param callback Invoked on every add/remove. + */ + void setOnFavoritesChanged(std::function)> callback); + + NuiFileExplorer::IFavoritesProvider* favoritesProvider() override + { + return this; + } + std::shared_ptr>> favorites() const override; + void addFavorite(std::filesystem::path const& path) override; + void removeFavorite(std::filesystem::path const& path) override; + protected: // Design smell: virtual bool isComplete() const; + /** + * @brief Replaces the favorites without telling the callback, for the list the side starts with. + */ + void setFavorites(std::vector const& favorites); + + /** + * @brief The context menu entries that add the one selected directory to the favorites or remove it, none for + * another selection. + */ + std::vector + favoriteMenuItems(std::vector const& selectedItems); + + /** + * @brief The UTF-8 path of @p item on its side, refused for the ".." entry and for names that are no single + * path segment. + */ + virtual std::expected itemPathText(NuiFileExplorer::Item const& item) const = 0; + + /** + * @brief The full path of each of @p items for the list of a confirmation, the listed path for an item whose + * name is no single path segment. + */ + std::vector pathList(std::vector const& items) const; + + /** + * @brief Shows the directory @p item stands for: the parent for "..", else the item's own path, the side may + * already be loading another directory. + */ + void navigateInto(NuiFileExplorer::Item const& item); + /** * @brief A new name is a single path segment: not empty, not "." or "..", without '/', '\\' or NUL. */ @@ -530,6 +580,10 @@ class SideModel : public NuiFileExplorer::ISideModel std::function refreshCallback_{nullptr}; bool reapplySelectionOnce_{false}; std::string dropMetadata_{}; + std::shared_ptr>> favorites_{ + std::make_shared>>() + }; + std::function)> onFavoritesChanged_{}; /** @brief Guards callbacks that can arrive after the side model is gone, like RPC replies and dialog closes. */ Utility::LifetimeToken lifetime_{}; }; \ No newline at end of file diff --git a/frontend/source/frontend/dialog/archive_transfer_dialog.cpp b/frontend/source/frontend/dialog/archive_transfer_dialog.cpp index 80d1afea..3de13031 100644 --- a/frontend/source/frontend/dialog/archive_transfer_dialog.cpp +++ b/frontend/source/frontend/dialog/archive_transfer_dialog.cpp @@ -37,6 +37,24 @@ std::string archiveCodecExtension(ArchiveCodec codec) return {}; } +int backendCompressionCode(ArchiveCodec codec) +{ + 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; + } + return 2; +} + ArchiveCodec archiveCodecFromExtension(std::string const& extension) { if (extension == ".gz" || extension == "gz") diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index e72b80cf..b7f4d7e1 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -16,8 +16,6 @@ #include #include #include -#include -#include #include #include @@ -96,17 +94,8 @@ LocalSideModel::LocalSideModel( }, 0., } - , favorites_{std::make_shared>>( - [this]() - { - std::vector paths; - paths.reserve(uiOptions_.localFavorites.size()); - for (auto const& str : uiOptions_.localFavorites) - paths.emplace_back(str); - return paths; - }() - )} { + setFavorites(uiOptions_.localFavorites); probeOpenerCapabilities(); } @@ -158,11 +147,6 @@ void LocalSideModel::probeOpenerCapabilities() ); } -void LocalSideModel::setOnFavoritesChanged(std::function)> callback) -{ - onFavoritesChanged_ = std::move(callback); -} - // --- IPlacesProvider --- namespace @@ -299,49 +283,6 @@ void LocalSideModel::requestDrives(std::function)> callback({}); } -// --- IFavoritesProvider --- - -std::shared_ptr>> LocalSideModel::favorites() const -{ - return favorites_; -} - -void LocalSideModel::addFavorite(std::filesystem::path const& path) -{ - auto& favs = favorites_->value(); - if (std::find(favs.begin(), favs.end(), path) != favs.end()) - return; - favorites_->push_back(path); - - if (onFavoritesChanged_) - { - std::vector strs; - strs.reserve(favorites_->value().size()); - for (auto const& fav : favorites_->value()) - strs.push_back(fav.generic_string()); - onFavoritesChanged_(std::move(strs)); - } -} - -void LocalSideModel::removeFavorite(std::filesystem::path const& path) -{ - auto& favs = favorites_->value(); - const auto it = std::find(favs.begin(), favs.end(), path); - if (it == favs.end()) - return; - favs.erase(it); - favorites_->modifyNow(); - - if (onFavoritesChanged_) - { - std::vector strs; - strs.reserve(favorites_->value().size()); - for (auto const& fav : favorites_->value()) - strs.push_back(fav.generic_string()); - onFavoritesChanged_(std::move(strs)); - } -} - void LocalSideModel::onActivateItem(NuiFileExplorer::Item const& item) { if (!item.isDirectoryLike()) @@ -351,41 +292,21 @@ void LocalSideModel::onActivateItem(NuiFileExplorer::Item const& item) return; } - // The item's own path, the side may already be loading another directory. Log::debug("Local onActivateItem: {}.", item.displayName()); - navigateTo(item.fullPath.empty() ? currentPath_.value() / item.name : item.fullPath); + navigateInto(item); } void LocalSideModel::onNewItem(NuiFileExplorer::Item::Type type) { - inputDialog_->open( - {.whatFor = language->get( - "remoteSideModel", type == NuiFileExplorer::Item::Type::Directory ? "newDirectory" : "newFile" - ), - .prompt = language->get( - "remoteSideModel", - type == NuiFileExplorer::Item::Type::Directory ? "enterNewDirectoryName" : "enterNewFileName" - ), - .headerText = language->get( - "remoteSideModel", type == NuiFileExplorer::Item::Type::Directory ? "createNewDirectory" : "createNewFile" - ), - .isPassword = false, - .onConfirm = [this, type](std::optional const& name) - { - if (!name) - return; - - if (!isValidItemName(*name)) - return showInvalidItemName(*name); - auto path = currentLocalPath().and_then( - [&name](Paths::LocalPath const& directory) - { - return Paths::LocalPathSegment::make(*name).transform( - [&directory](Paths::LocalPathSegment const& segment) - { - return directory / segment; - } - ); - } + promptNewItem( + type, + [this, type](Paths::LocalPath const& path) + { + if (type == NuiFileExplorer::Item::Type::Directory) + return createItem( + "RpcFilesystem::createDirectory", + "path", + path, + language->get("localSideModel", "createDirectoryFailedTitle") ); if (!path) return showPathRefused(language->get("localSideModel", "createItemFailedTitle"), path.error()); @@ -396,35 +317,15 @@ void LocalSideModel::onNewItem(NuiFileExplorer::Item::Type type) Nui::val args = Nui::val::object(); args.set("path", path->toUtf8()); - Nui::RpcClient::callWithBackChannel( - "RpcFilesystem::createDirectory", - [this](Nui::val val) - { - if (!val.hasOwnProperty("success")) - { - Log::error("Invalid response from RpcFilesystem::createDirectory"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "createDirectoryFailedTitle"), - .text = language->get("localSideModel", "invalidResponseFromBackend"), - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - - const auto success = val["success"].as(); - if (!success) - { - const auto error = language->translate(val["error"].as()); - Log::error("Failed to create directory: {}", error); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "createDirectoryFailedTitle"), - .text = error, - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } +void LocalSideModel::createItem( + char const* function, + char const* pathArgument, + Paths::LocalPath const& path, + std::string failedTitle +) +{ + Nui::val args = Nui::val::object(); + args.set(pathArgument, path.toUtf8()); Log::info("Directory created successfully"); // Refresh list: @@ -712,6 +613,66 @@ void LocalSideModel::onRename(NuiFileExplorer::Item const& item) promptRename(item, localPathOf(item)); } +namespace +{ + std::optional presentField(Nui::val const& source, char const* key) + { + if (!source.hasOwnProperty(key)) + return std::nullopt; + auto value = source[key]; + if (value.isNull() || value.isUndefined()) + return std::nullopt; + return value; + } + + template + ValueT fieldOrDefault(Nui::val const& source, char const* key) + { + return presentField(source, key) + .transform( + [](Nui::val const& value) + { + return value.as(); + } + ) + .value_or(ValueT{}); + } + + /** + * @brief The entry the backend describes in @p source, with the entry a symlink resolves to when @p source + * carries it. + */ + SharedData::DirectoryEntry directoryEntryOf(Nui::val const& source) + { + SharedData::DirectoryEntry entry{ + .name = source["name"].as(), + .type = SharedData::fileTypeFromStdFilesystemType( + static_cast(source["type"].as()) + ), + .size = fieldOrDefault(source, "size"), + .uid = fieldOrDefault(source, "uid"), + .gid = fieldOrDefault(source, "gid"), + .owner = fieldOrDefault(source, "owner"), + .group = fieldOrDefault(source, "group"), + .permissions = static_cast(fieldOrDefault(source, "permissions")), + .atime = fieldOrDefault(source, "atime"), + .atimeNsec = fieldOrDefault(source, "atimeNsec"), + .createTime = fieldOrDefault(source, "createTime"), + .createTimeNsec = fieldOrDefault(source, "createTimeNsec"), + .mtime = fieldOrDefault(source, "mtime"), + .mtimeNsec = fieldOrDefault(source, "mtimeNsec"), + .acl = fieldOrDefault(source, "acl"), + }; + if (const auto valid = presentField(source, "nameIsValidUtf8"); valid) + entry.nameIsValidUtf8 = valid->as(); + if (const auto link = presentField(source, "linkTarget"); link) + entry.linkTarget = link->as(); + if (const auto target = presentField(source, "resolvedTarget"); target && entry.isSymlink()) + entry.resolvedTarget = std::make_shared(directoryEntryOf(*target)); + return entry; + } +} + void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) { // listFiles only carries the lean field set; fetch the full entry (atime, birthtime, acl, ...) @@ -748,74 +709,7 @@ 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 - { - if (!source.hasOwnProperty(key)) - return 0; - const auto field = source[key]; - if (field.isNull() || field.isUndefined()) - return 0; - return field.template as(); - }; - const auto pickU32 = [&source](char const* key) -> std::uint32_t - { - if (!source.hasOwnProperty(key)) - return 0; - const auto field = source[key]; - if (field.isNull() || field.isUndefined()) - return 0; - return field.template as(); - }; - const auto pickString = [&source](char const* key) -> std::string - { - if (!source.hasOwnProperty(key)) - return {}; - const auto field = source[key]; - if (field.isNull() || field.isUndefined()) - return {}; - return field.template as(); - }; - - SharedData::DirectoryEntry out{ - .name = source["name"].template as(), - .type = SharedData::fileTypeFromStdFilesystemType( - static_cast(source["type"].template as()) - ), - .size = pickU64("size"), - .uid = pickU32("uid"), - .gid = pickU32("gid"), - .owner = pickString("owner"), - .group = pickString("group"), - .permissions = static_cast(pickU32("permissions")), - .atime = pickU64("atime"), - .atimeNsec = pickU32("atimeNsec"), - .createTime = pickU64("createTime"), - .createTimeNsec = pickU32("createTimeNsec"), - .mtime = pickU64("mtime"), - .mtimeNsec = pickU32("mtimeNsec"), - .acl = pickString("acl"), - }; - if (source.hasOwnProperty("nameIsValidUtf8")) - out.nameIsValidUtf8 = source["nameIsValidUtf8"].template as(); - if (source.hasOwnProperty("linkTarget")) - { - const auto link = source["linkTarget"]; - if (!link.isNull() && !link.isUndefined()) - out.linkTarget = link.template as(); - } - return out; - }; - - const auto entryVal = val["entry"]; - auto full = extractFull(entryVal); - if (full.type == SharedData::FileType::Symlink && entryVal.hasOwnProperty("resolvedTarget")) - { - const auto target = entryVal["resolvedTarget"]; - if (!target.isNull() && !target.isUndefined()) - full.resolvedTarget = std::make_shared(extractFull(target)); - } + const auto full = directoryEntryOf(val["entry"]); std::string resolvedPath{}; if (val.hasOwnProperty("resolvedPath") && val["resolvedPath"].isString()) @@ -912,83 +806,12 @@ 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 - { - if (!source.hasOwnProperty(key)) - return 0; - const auto field = source[key]; - if (field.isNull() || field.isUndefined()) - return 0; - return field.template as(); - }; - const auto pickU32 = [&source](char const* key) -> std::uint32_t - { - if (!source.hasOwnProperty(key)) - return 0; - const auto field = source[key]; - if (field.isNull() || field.isUndefined()) - return 0; - return field.template as(); - }; - const auto pickString = [&source](char const* key) -> std::string - { - if (!source.hasOwnProperty(key)) - return {}; - const auto field = source[key]; - if (field.isNull() || field.isUndefined()) - return {}; - return field.template as(); - }; - - SharedData::DirectoryEntry entry{ - .name = source["name"].template as(), - .type = SharedData::fileTypeFromStdFilesystemType( - static_cast(source["type"].template as()) - ), - .size = pickU64("size"), - .uid = pickU32("uid"), - .gid = pickU32("gid"), - .owner = pickString("owner"), - .group = pickString("group"), - .permissions = static_cast(pickU32("permissions")), - .atime = pickU64("atime"), - .atimeNsec = pickU32("atimeNsec"), - .createTime = pickU64("createTime"), - .createTimeNsec = pickU32("createTimeNsec"), - .mtime = pickU64("mtime"), - .mtimeNsec = pickU32("mtimeNsec"), - }; - if (source.hasOwnProperty("nameIsValidUtf8")) - entry.nameIsValidUtf8 = source["nameIsValidUtf8"].template as(); - if (source.hasOwnProperty("linkTarget")) - { - const auto link = source["linkTarget"]; - if (!link.isNull() && !link.isUndefined()) - entry.linkTarget = link.template as(); - } - return entry; - }; - - std::vector directoryEntries; - const auto files = val["files"]; - const auto fileCount = files["length"].as(); - directoryEntries.reserve(static_cast(fileCount)); - for (int idx = 0; idx < fileCount; ++idx) - { - const auto file = files[idx]; - auto entry = extractEntry(file); - if (entry.type == SharedData::FileType::Symlink && file.hasOwnProperty("resolvedTarget")) - { - const auto target = file["resolvedTarget"]; - if (!target.isNull() && !target.isUndefined()) - { - entry.resolvedTarget = std::make_shared(extractEntry(target)); - } - } - directoryEntries.push_back(std::move(entry)); - } + std::vector directoryEntries; + const auto files = val["files"]; + const auto fileCount = files["length"].as(); + directoryEntries.reserve(static_cast(fileCount)); + for (int index = 0; index < fileCount; ++index) + directoryEntries.push_back(directoryEntryOf(files[index])); onDirectoryListing(directory, std::move(directoryEntries)); }, @@ -1092,26 +915,7 @@ LocalSideModel::contextMenuItems(std::vector const& selec items.push_back(Snc::PopupMenu::separator()); } - if (singleItem && selectedItems.front().isDirectoryLike() && !selectedItems.front().isParentLink) - { - const auto& selPath = selectedItems.front().fullPath; - const auto& favs = favorites_->value(); - const bool isFav = std::find(favs.begin(), favs.end(), selPath) != favs.end(); - items.push_back( - Snc::PopupMenu::item( - language->get("fileExplorer", "contextMenu", isFav ? "removeFromFavorites" : "addToFavorites"), - isFav ? Ui5Icons::unfavorite() : Ui5Icons::add_favorite(), - [this, selPath, isFav]() - { - if (isFav) - removeFavorite(selPath); - else - addFavorite(selPath); - } - ) - ); - items.push_back(Snc::PopupMenu::separator()); - } + std::ranges::move(favoriteMenuItems(selectedItems), std::back_inserter(items)); const auto remainingItems = std::vector{ Snc::PopupMenu::item( @@ -1237,6 +1041,16 @@ std::expected LocalSideModel::currentLocalPa return Paths::LocalPath::fromUtf8(listedPath_.generic_string()); } +std::expected LocalSideModel::itemPathText(NuiFileExplorer::Item const& item) const +{ + return localPathOf(item).transform( + [](Paths::LocalPath const& path) + { + return path.toUtf8(); + } + ); +} + std::expected LocalSideModel::localPathOf(NuiFileExplorer::Item const& item) const { if (item.isParentLink) @@ -1261,29 +1075,6 @@ bool LocalSideModel::isComplete() const return SideModel::isComplete(); } -namespace -{ - /** @brief Same mapping as RemoteSideModel — frontend-local ArchiveCodec → - * raw TarArchive::Compression int value used by the backend wire. */ - int backendCompressionCode(ArchiveCodec codec) - { - 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; - } - return 2; - } -} - void LocalSideModel::onTransferAsArchive(std::vector const& items) { CHECK_COMPLETE(); diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index e9899c59..4cf64d4f 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -13,8 +13,6 @@ #include #include #include -#include -#include #include #include @@ -84,62 +82,8 @@ RemoteSideModel::RemoteSideModel( }, 0., } - , favorites_{std::make_shared>>( - [&initialFavorites]() - { - std::vector paths; - paths.reserve(initialFavorites.size()); - for (auto const& str : initialFavorites) - paths.emplace_back(str); - return paths; - }() - )} -{} - -void RemoteSideModel::setOnFavoritesChanged(std::function)> callback) -{ - onFavoritesChanged_ = std::move(callback); -} - -std::shared_ptr>> RemoteSideModel::favorites() const { - return favorites_; -} - -void RemoteSideModel::addFavorite(std::filesystem::path const& path) -{ - auto& favs = favorites_->value(); - if (std::find(favs.begin(), favs.end(), path) != favs.end()) - return; - favorites_->push_back(path); - - if (onFavoritesChanged_) - { - std::vector strs; - strs.reserve(favorites_->value().size()); - for (auto const& fav : favorites_->value()) - strs.push_back(fav.generic_string()); - onFavoritesChanged_(std::move(strs)); - } -} - -void RemoteSideModel::removeFavorite(std::filesystem::path const& path) -{ - auto& favs = favorites_->value(); - const auto it = std::find(favs.begin(), favs.end(), path); - if (it == favs.end()) - return; - favs.erase(it); - favorites_->modifyNow(); - - if (onFavoritesChanged_) - { - std::vector strs; - strs.reserve(favorites_->value().size()); - for (auto const& fav : favorites_->value()) - strs.push_back(fav.generic_string()); - onFavoritesChanged_(std::move(strs)); - } + setFavorites(initialFavorites); } std::vector @@ -151,26 +95,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele std::vector items; - if (singleItem && selectedItems.front().isDirectoryLike() && !selectedItems.front().isParentLink) - { - const auto& selPath = selectedItems.front().fullPath; - const auto& favs = favorites_->value(); - const bool isFav = std::find(favs.begin(), favs.end(), selPath) != favs.end(); - items.push_back( - Snc::PopupMenu::item( - language->get("fileExplorer", "contextMenu", isFav ? "removeFromFavorites" : "addToFavorites"), - isFav ? Ui5Icons::unfavorite() : Ui5Icons::add_favorite(), - [this, selPath, isFav]() - { - if (isFav) - removeFavorite(selPath); - else - addFavorite(selPath); - } - ) - ); - items.push_back(Snc::PopupMenu::separator()); - } + std::ranges::move(favoriteMenuItems(selectedItems), std::back_inserter(items)); // "Open" variants on the remote side ultimately route the downloaded file through the // local Opener (xdg-desktop-portal / ShellExecute), so gate them on the same capability @@ -202,7 +127,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele Ui5Icons::open_folder(), [this, selectedItems]() { - onDownloadAndOpen(selectedItems.front(), false); + onDownloadAndOpen(selectedItems.front(), false, false); }, !singleItem || fileTracking_ == nullptr || !canOpen ), @@ -211,7 +136,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele Ui5Icons::open_command_field(), [this, selectedItems]() { - onDownloadAndOpen(selectedItems.front(), true); + onDownloadAndOpen(selectedItems.front(), true, false); }, !singleItem || fileTracking_ == nullptr || !canOpen ), @@ -220,7 +145,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele Ui5Icons::show(), [this, selectedItems]() { - onWatchDownloadAndOpen(selectedItems.front(), false); + onDownloadAndOpen(selectedItems.front(), false, true); }, !singleItem || fileTracking_ == nullptr || !canOpen ), @@ -229,7 +154,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele Ui5Icons::show(), [this, selectedItems]() { - onWatchDownloadAndOpen(selectedItems.front(), true); + onDownloadAndOpen(selectedItems.front(), true, true); }, !singleItem || fileTracking_ == nullptr || !canOpen ), @@ -295,6 +220,16 @@ std::expected RemoteSideModel::remotePathOf ); } +std::expected RemoteSideModel::itemPathText(NuiFileExplorer::Item const& item) const +{ + return remotePathOf(item).transform( + [](Paths::RemotePath const& path) + { + return path.value(); + } + ); +} + std::expected RemoteSideModel::childOfCurrentPath(std::string_view name) const { return currentRemotePath().and_then( @@ -323,13 +258,12 @@ void RemoteSideModel::onActivateItem(NuiFileExplorer::Item const& item) { const bool canOpen = localModel_ ? localModel_->openerCapabilities().canOpenFile : true; if (fileTracking_ != nullptr && canOpen) - onDownloadAndOpen(item, false); + onDownloadAndOpen(item, false, false); return; } - // The item's own path, the side may already be loading another directory. Log::debug("Remote onActivateItem: {}.", item.displayName()); - navigateTo(item.fullPath.empty() ? currentPath_.value() / item.name : item.fullPath); + navigateInto(item); } void RemoteSideModel::onNewItem(NuiFileExplorer::Item::Type type) @@ -337,90 +271,31 @@ void RemoteSideModel::onNewItem(NuiFileExplorer::Item::Type type) CHECK_COMPLETE(); Log::info("New item requested: {}", static_cast(type)); - if (type == NuiFileExplorer::Item::Type::Directory) - { - inputDialog_->open({ - .whatFor = language->get("remoteSideModel", "newDirectory"), - .prompt = language->get("remoteSideModel", "enterNewDirectoryName"), - .headerText = language->get("remoteSideModel", "createNewDirectory"), - .isPassword = false, - .onConfirm = [this](std::optional const& name) - { - if (!name) - return; - - if (!isValidItemName(*name)) - return showInvalidItemName(*name); - auto path = childOfCurrentPath(*name); - if (!path) - return showPathRefused(language->get("remoteSideModel", "createNewDirectory"), path.error()); - Log::info("Creating new directory: {}", path->value()); - fileEngine_->createDirectory( - *path, - [this](bool success, std::string const& info) - { - if (!success) - { - Log::error("Failed to create directory: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = "", - .text = info, - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - // Refresh list from server: - navigateTo(currentPath_.value()); - } - ); - }, - }); - } - else if (type == NuiFileExplorer::Item::Type::Regular) - { - inputDialog_->open({ - .whatFor = language->get("remoteSideModel", "newFile"), - .prompt = language->get("remoteSideModel", "enterNewFileName"), - .headerText = language->get("remoteSideModel", "createNewFile"), - .isPassword = false, - .onConfirm = [this](std::optional const& name) - { - if (!name) - return; - - if (!isValidItemName(*name)) - return showInvalidItemName(*name); - auto path = childOfCurrentPath(*name); - if (!path) - return showPathRefused(language->get("remoteSideModel", "createNewFile"), path.error()); - Log::info("Creating new file: {}", path->value()); - fileEngine_->createFile( - *path, - [this](bool success, std::string const& info) - { - if (!success) - { - Log::error("Failed to create file: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = "", - .text = info, - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - // Refresh list from server: - navigateTo(currentPath_.value()); - } - ); - }, - }); - } - else - { - // TODO: create file - } + promptNewItem( + type, + [this, type](Paths::RemotePath const& path) + { + const bool isDirectory = type == NuiFileExplorer::Item::Type::Directory; + auto onCreated = lifetime_.guarded( + [this, isDirectory](bool success, std::string const& info) + { + if (success) + return navigateTo(currentPath_.value()); + Log::error("Failed to create {}: {}", isDirectory ? "directory" : "file", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = isDirectory ? language->get("remoteSideModel", "createDirectoryFailedTitle") + : language->get("remoteSideModel", "createFileFailedTitle"), + .text = language->translate(info), + .buttons = ConfirmDialog::Button::Ok, + }); + } + ); + if (isDirectory) + return fileEngine_->createDirectory(path, std::move(onCreated)); + fileEngine_->createFile(path, std::move(onCreated)); + } + ); } void RemoteSideModel::onError(NuiFileExplorer::Error const& fileExplorerError) @@ -789,33 +664,6 @@ void RemoteSideModel::onTransfer( ); } -namespace -{ - /** - * @brief Translate the frontend-local ArchiveCodec to the integer value the - * backend expects (the raw int of TarArchive::Compression). The order - * must match tar_archive/compression.hpp: Auto=0, None=1, Gzip=2, - * Bzip2=3, Zstd=4, Xz=5. - */ - int backendCompressionCode(ArchiveCodec codec) - { - 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; - } - return 2; - } -} - void RemoteSideModel::onTransferAsArchive(std::vector const& items) { CHECK_COMPLETE(); @@ -978,7 +826,7 @@ void RemoteSideModel::generatePathBoxSuggestions( ); } -void RemoteSideModel::onDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith) +void RemoteSideModel::onDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith, bool watch) { CHECK_COMPLETE(); @@ -988,48 +836,11 @@ void RemoteSideModel::onDownloadAndOpen(NuiFileExplorer::Item const& item, bool return; } + // The download goes into an instance directory of its own, which the watch observes. Nui::RpcClient::callWithBackChannel( "FileTracking::createInstance", - [this, item, openWith](Nui::val response) - { - if (!response.hasOwnProperty("success") || !response["success"].as()) - { - Log::error("FileTracking: createInstance failed"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("fileTrackingPanel", "instanceCreationFailed"), - .text = language->get("fileTrackingPanel", "instanceCreationFailedDesc"), - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - onFileTrackingInstanceCreated( - item, - openWith, - false, - Ids::makeInstanceId(response["instanceId"].as()), - response["instanceDir"].as() - ); - } - ); -} - -void RemoteSideModel::onWatchDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith) -{ - CHECK_COMPLETE(); - - if (fileTracking_ == nullptr) - { - Log::error("File tracking panel is not available, cannot watch/download/open file."); - return; - } - - // First create an instance where stuff to watch is placed: - Nui::RpcClient::callWithBackChannel( - "FileTracking::createInstance", - [this, item, openWith](Nui::val response) - { - if (!response.hasOwnProperty("success") || !response["success"].as()) + lifetime_.guarded( + [this, item, openWith, watch](Nui::val response) { Log::error("FileTracking: createInstance failed"); confirmDialog_->open({ @@ -1040,14 +851,7 @@ void RemoteSideModel::onWatchDownloadAndOpen(NuiFileExplorer::Item const& item, }); return; } - onFileTrackingInstanceCreated( - item, - openWith, - true, - Ids::makeInstanceId(response["instanceId"].as()), - response["instanceDir"].as() - ); - } + ) ); } diff --git a/frontend/source/frontend/file_explorer/side_model.cpp b/frontend/source/frontend/file_explorer/side_model.cpp index 41bf277c..029208b7 100644 --- a/frontend/source/frontend/file_explorer/side_model.cpp +++ b/frontend/source/frontend/file_explorer/side_model.cpp @@ -1,6 +1,13 @@ #include #include #include +#include + +#include +#include + +#include +#include void SideModel::operationQueue(OperationQueue* operationQueue) { @@ -21,6 +28,121 @@ std::shared_ptr SideModel::engine() return fileEngine_; } +void SideModel::setOnFavoritesChanged(std::function)> callback) +{ + onFavoritesChanged_ = std::move(callback); +} + +std::shared_ptr>> SideModel::favorites() const +{ + return favorites_; +} + +void SideModel::setFavorites(std::vector const& favorites) +{ + std::vector paths; + paths.reserve(favorites.size()); + std::ranges::transform( + favorites, + std::back_inserter(paths), + [](std::string const& favorite) + { + return std::filesystem::path{favorite}; + } + ); + *favorites_ = std::move(paths); +} + +void SideModel::addFavorite(std::filesystem::path const& path) +{ + if (std::ranges::contains(favorites_->value(), path)) + return; + favorites_->push_back(path); + notifyFavoritesChanged(); +} + +void SideModel::removeFavorite(std::filesystem::path const& path) +{ + if (std::erase(favorites_->value(), path) == 0) + return; + favorites_->modifyNow(); + notifyFavoritesChanged(); +} + +void SideModel::notifyFavoritesChanged() +{ + if (!onFavoritesChanged_) + return; + std::vector favorites; + favorites.reserve(favorites_->value().size()); + std::ranges::transform( + favorites_->value(), + std::back_inserter(favorites), + [](std::filesystem::path const& favorite) + { + return favorite.generic_string(); + } + ); + onFavoritesChanged_(std::move(favorites)); +} + +std::vector +SideModel::favoriteMenuItems(std::vector const& selectedItems) +{ + if (selectedItems.size() != 1 || !selectedItems.front().isDirectoryLike()) + return {}; + const auto pathText = itemPathText(selectedItems.front()); + if (!pathText) + return {}; + + const auto path = std::filesystem::path{*pathText}; + const bool isFavorite = std::ranges::contains(favorites_->value(), path); + return { + ScriptNuiComponents::PopupMenu::item( + language->get("fileExplorer", "contextMenu", isFavorite ? "removeFromFavorites" : "addToFavorites"), + isFavorite ? Ui5Icons::unfavorite() : Ui5Icons::add_favorite(), + lifetime_.guarded( + [this, path, isFavorite]() + { + if (isFavorite) + removeFavorite(path); + else + addFavorite(path); + } + ) + ), + ScriptNuiComponents::PopupMenu::separator(), + }; +} + +std::vector +SideModel::pathList(std::vector const& items) const +{ + std::vector list; + list.reserve(items.size()); + std::ranges::transform( + items, + std::back_inserter(list), + [this](NuiFileExplorer::Item const& item) + { + return ConfirmDialog::OpenOptions::ListElement{ + .text = itemPathText(item).value_or(item.fullPath.generic_string()), + }; + } + ); + return list; +} + +void SideModel::navigateInto(NuiFileExplorer::Item const& item) +{ + if (item.isParentLink) + return navigateTo(item.fullPath); + const auto path = itemPathText(item); + if (!path) + return showPathRefused(language->get("sideModel", "failedToListDirectory"), path.error()); + navigateTo(std::filesystem::path{*path}); +} + bool SideModel::isValidItemName(std::string_view name) { if (name.empty() || name == "." || name == "..") From 9f31cb90458229cdd5a3e9753b3989eebdc8eb4d Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:38:41 +0200 Subject: [PATCH 05/62] Drops on ".." go to the parent and drops on a directory link into it Items dropped on the ".." entry go into the parent directory and items dropped or pasted onto a link to a directory go into it; a drop or paste onto a directory whose name is not valid UTF-8 is refused with a message instead of going elsewhere --- .../file_explorer/file_explorer_texts.cpp | 4 +++ .../drop_event_extractor.hpp | 1 + .../include/nui-file-explorer/error.hpp | 5 ++++ .../side_model_interface.hpp | 5 ++-- .../nui-file-explorer/transfer_target.hpp | 27 +++++++++++++++++++ .../drop_event_extractor.cpp | 13 +++++++-- .../source/nui-file-explorer/side.cpp | 17 +++++++----- static/assets/languages/de_DE.yaml | 1 + static/assets/languages/en_US.yaml | 1 + 9 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 nui-file-explorer/include/nui-file-explorer/transfer_target.hpp diff --git a/frontend/source/frontend/file_explorer/file_explorer_texts.cpp b/frontend/source/frontend/file_explorer/file_explorer_texts.cpp index f84cff39..04adf2ce 100644 --- a/frontend/source/frontend/file_explorer/file_explorer_texts.cpp +++ b/frontend/source/frontend/file_explorer/file_explorer_texts.cpp @@ -68,6 +68,10 @@ std::string fileExplorerErrorText(NuiFileExplorer::Error const& error) return language->get("fileExplorer", "errors", "dropOnSameSide"); case DropDataUnreadable: return fmt::format(fmt::runtime(language->get("fileExplorer", "errors", "dropDataUnreadable")), error.detail); + case DropTargetNameInvalid: + return fmt::format( + fmt::runtime(language->get("fileExplorer", "errors", "dropTargetNameInvalid")), error.detail + ); } return error.detail; } diff --git a/nui-file-explorer/include/nui-file-explorer/drop_event_extractor.hpp b/nui-file-explorer/include/nui-file-explorer/drop_event_extractor.hpp index 9bb91638..ee905e2a 100644 --- a/nui-file-explorer/include/nui-file-explorer/drop_event_extractor.hpp +++ b/nui-file-explorer/include/nui-file-explorer/drop_event_extractor.hpp @@ -12,6 +12,7 @@ namespace NuiFileExplorer bool delegatedToBackend = false; std::optional> externDroppedItems = std::nullopt; std::optional isInternalDropFromLeftSide = std::nullopt; + /** @brief Where the items go below the directory of the side they were dropped on, see transferTargetOf. */ std::optional internalDropSubdir = std::nullopt; bool issueWebkitWarning = false; }; diff --git a/nui-file-explorer/include/nui-file-explorer/error.hpp b/nui-file-explorer/include/nui-file-explorer/error.hpp index f97958fa..a1705763 100644 --- a/nui-file-explorer/include/nui-file-explorer/error.hpp +++ b/nui-file-explorer/include/nui-file-explorer/error.hpp @@ -15,6 +15,11 @@ namespace NuiFileExplorer DropOnSameSide, /// The drag and drop payload could not be read. The detail holds the parser error. DropDataUnreadable, + /** + * @brief Items were dropped or pasted onto a directory whose name is not valid UTF-8. The detail holds the + * name as shown. + */ + DropTargetNameInvalid, }; struct Error diff --git a/nui-file-explorer/include/nui-file-explorer/side_model_interface.hpp b/nui-file-explorer/include/nui-file-explorer/side_model_interface.hpp index 2b07424d..08fbda69 100644 --- a/nui-file-explorer/include/nui-file-explorer/side_model_interface.hpp +++ b/nui-file-explorer/include/nui-file-explorer/side_model_interface.hpp @@ -107,14 +107,15 @@ namespace NuiFileExplorer /** * @brief Triggered when items are requested to be downloaded / uploaded. * - * @param subDir Optional sub-directory within the current path to transfer into. + * @param subDir Where the items go below the directory the other side shows: a child directory, ".." for + * its parent, nothing for the directory itself (see transferTargetOf). */ virtual void onTransfer(std::vector const& items, std::optional const& subDir) = 0; /** * @brief Triggered when items are requested to be downloaded / uploaded via drop. * - * @param subDir Optional sub-directory within the current path to transfer into. + * @param subDir Where the items go, as for @ref onTransfer. * @param issueWebkitWarning Whether to show a warning that dropping external items is not fully supported on * WebKit-based engines due to technical limitations and bugs. */ diff --git a/nui-file-explorer/include/nui-file-explorer/transfer_target.hpp b/nui-file-explorer/include/nui-file-explorer/transfer_target.hpp new file mode 100644 index 00000000..19f8aa7f --- /dev/null +++ b/nui-file-explorer/include/nui-file-explorer/transfer_target.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace NuiFileExplorer +{ + /** + * @brief Where items dropped or pasted onto @p item go, relative to the directory its side shows: ".." for the + * parent entry, the name of a directory or of a link to one, nothing for other items, which leaves them in + * the directory itself. A directory whose name is not valid UTF-8 cannot be named, so it is no target. + */ + inline std::expected, Error> transferTargetOf(Item const& item) + { + if (item.isParentLink) + return std::string{".."}; + if (!item.isDirectoryLike()) + return std::nullopt; + if (!item.nameIsValidUtf8) + return std::unexpected(Error{.code = ErrorCode::DropTargetNameInvalid, .detail = item.name}); + return item.name; + } +} diff --git a/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp b/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp index f6fca3a0..7607f88c 100644 --- a/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp +++ b/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -131,8 +132,16 @@ namespace NuiFileExplorer const auto types = dataTransferOpt->types(); DropEventResult result; - if (droppedOnItem && droppedOnItem->type == Item::Type::Directory) - result.internalDropSubdir = droppedOnItem->name; + if (droppedOnItem) + { + auto target = transferTargetOf(*droppedOnItem); + if (!target) + { + sideModel.onError(target.error()); + return std::nullopt; + } + result.internalDropSubdir = *std::move(target); + } const auto files = dataTransferOpt->files(); if (files.length() > 0 && STRINGIZE_EXPANDED(BROWSER_ENGINE) == "webview2"s) diff --git a/nui-file-explorer/source/nui-file-explorer/side.cpp b/nui-file-explorer/source/nui-file-explorer/side.cpp index 0c4f4f0a..785cc751 100644 --- a/nui-file-explorer/source/nui-file-explorer/side.cpp +++ b/nui-file-explorer/source/nui-file-explorer/side.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -183,13 +184,15 @@ namespace NuiFileExplorer const auto& copiedFiles = impl_->otherSide->impl_->copiedFiles; if (!copiedFiles.empty()) { - // If only a single item is selected and its a directory, set that as the target subdir for the - // transfer. Otherwise, transfer to the current directory. - std::optional targetSubdir = std::nullopt; - auto selectedItems = this->selectedItems(); - if (selectedItems.size() == 1 && selectedItems.front().isDirectory()) - targetSubdir = selectedItems.front().name; - impl_->otherSide->model().onTransfer(copiedFiles, targetSubdir); + // A single selected directory takes the items, else the directory this side shows. + const auto selectedItems = this->selectedItems(); + const auto target = selectedItems.size() == 1 + ? transferTargetOf(selectedItems.front()) + : std::expected, Error>{std::nullopt}; + if (!target) + impl_->model->onError(target.error()); + else + impl_->otherSide->model().onTransfer(copiedFiles, *target); } } } diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index 571cbd4a..f43f7395 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -70,6 +70,7 @@ fileExplorer: errors: dropOnSameSide: "Das Verschieben von Elementen per Drag and Drop innerhalb derselben Seite wird nicht unterstützt." dropDataUnreadable: "Die Drag-and-Drop-Daten konnten nicht gelesen werden: {}" + dropTargetNameInvalid: "In '{}' kann nichts übertragen werden, weil der Name kein gültiges UTF-8 ist." newSessionDialog: title: "Neue Sitzung erstellen" validationMessage: "Bitte füllen Sie alle Pflichtfelder aus." diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index 59d7a35a..fb32bbb0 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -70,6 +70,7 @@ fileExplorer: errors: dropOnSameSide: "Moving items by drag and drop within the same side is not supported." dropDataUnreadable: "Failed to read the drag and drop data: {}" + dropTargetNameInvalid: "Nothing can be transferred into '{}' because its name is not valid UTF-8." newSessionDialog: title: "Create New Session" validationMessage: "Please fill in all required fields." From f238cc90e1b01beffd1ccc640df1a0730d23c89e Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:41:00 +0200 Subject: [PATCH 06/62] Navigating normalizes the path with the side's path type Navigating normalizes the path with the side's path type, so a path typed with a trailing separator lists the directory without it and ".." leads to its parent; each side computes the parent with RemotePath::parent or LocalPath::parent, which gives a drive root like C:/ on Windows; a failed listing shows the listed directory and its items again, sorted, without listing it again on either side --- .../file_explorer/local_side_model.hpp | 2 +- .../file_explorer/remote_side_model.hpp | 1 + .../frontend/file_explorer/side_model.hpp | 12 ++-- .../file_explorer/local_side_model.cpp | 57 +++---------------- .../file_explorer/remote_side_model.cpp | 11 +++- .../frontend/file_explorer/side_model.cpp | 26 +++++---- 6 files changed, 39 insertions(+), 70 deletions(-) diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 725fa688..754813dd 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -98,7 +98,7 @@ class LocalSideModel std::expected currentLocalPath() const; bool isComplete() const override; void navigateTo(std::filesystem::path const& path) override; - bool hasParent(std::filesystem::path const& directory) const override; + std::optional parentOf(std::filesystem::path const& directory) const override; void generatePathBoxSuggestions( std::filesystem::path const& path, diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index 78da0bca..09dd4292 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -84,6 +84,7 @@ class RemoteSideModel } void navigateTo(std::filesystem::path const& path) override; void setLocalModel(LocalSideModel* model); + std::optional parentOf(std::filesystem::path const& directory) const override; /** * @brief The directory the shown items were listed from, an error before the first listing. diff --git a/frontend/include/frontend/file_explorer/side_model.hpp b/frontend/include/frontend/file_explorer/side_model.hpp index 604e233c..f5565d17 100644 --- a/frontend/include/frontend/file_explorer/side_model.hpp +++ b/frontend/include/frontend/file_explorer/side_model.hpp @@ -547,12 +547,14 @@ class SideModel protected: /** - * @brief The directory has a parent to offer as "..". + * @brief The parent of the listed @p directory to offer as "..", nullopt for a root. */ - virtual bool hasParent(std::filesystem::path const& directory) const - { - return directory.has_relative_path(); - } + virtual std::optional parentOf(std::filesystem::path const& directory) const = 0; + + /** + * @brief Shows the directory of the shown items again after a navigation failed, without listing it again. + */ + void revertNavigation(); /** * @brief Shows the listing of @p directory. A reply for a directory the side has navigated away from is dropped, diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index b7f4d7e1..c66720d6 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -731,68 +731,25 @@ void LocalSideModel::onError(NuiFileExplorer::Error const& fileExplorerError) }); } -bool LocalSideModel::hasParent(std::filesystem::path const& directory) const +std::optional LocalSideModel::parentOf(std::filesystem::path const& directory) const { - // A drive root like "C:/" has no parent either. - return Paths::LocalPath::fromUtf8(directory.generic_string()) - .transform( - [](Paths::LocalPath const& path) - { - return !path.isRoot(); - } - ) - .value_or(false); + return FileExplorer::parentOf(directory.generic_string()); } void LocalSideModel::navigateTo(std::filesystem::path const& path) { - auto lexicallyNormal = path.lexically_normal(); - Log::info("Local navigating to: {}", lexicallyNormal.generic_string()); + const auto target = FileExplorer::navigationTarget(path.generic_string()); + Log::info("Local navigating to: {}", target); preNavigatePath_ = currentPath_.value(); - currentPath_ = lexicallyNormal; + currentPath_ = std::filesystem::path{target}; Nui::val args = Nui::val::object(); args.set("path", currentPath_.value().generic_string()); Nui::RpcClient::callWithBackChannel( "RpcFilesystem::listFiles", - [this, directory = currentPath_.value()](Nui::val val) - { - Log::debug("Received directory listing response"); - if (directory != currentPath_.value()) - { - Log::debug("Dropping the listing of '{}', the side moved on.", directory.generic_string()); - return; - } - auto revertNavigation = [this]() - { - if (currentPath_.value() != preNavigatePath_) - { - currentPath_ = preNavigatePath_; - currentPath_.modifyNow(); - } - // Re-render the existing items so the side's loading hint clears even though - // no new directory data arrived. updateItems preserves selection across the - // refresh, so the user lands back on what they had before the failed nav. - if (refreshCallback_) - refreshCallback_(false, true); - }; - - if (!val.hasOwnProperty("success")) - { - Log::error("Invalid response from RpcFilesystem::listFiles"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "failedToListFiles"), - .text = language->get("localSideModel", "invalidResponseFromBackend"), - .buttons = ConfirmDialog::Button::Ok, - }); - revertNavigation(); - return; - } - - const auto success = val["success"].as(); - if (!success) + lifetime_.guarded( + [this, directory = currentPath_.value()](Nui::val val) { const auto error = language->translate(val["error"].as()); Log::error("Failed to list files: {}", error); diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 4cf64d4f..b504d45e 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -220,6 +220,11 @@ std::expected RemoteSideModel::remotePathOf ); } +std::optional RemoteSideModel::parentOf(std::filesystem::path const& directory) const +{ + return FileExplorer::parentOf(directory.generic_string()); +} + std::expected RemoteSideModel::itemPathText(NuiFileExplorer::Item const& item) const { return remotePathOf(item).transform( @@ -792,10 +797,10 @@ void RemoteSideModel::navigateTo(std::filesystem::path const& path) { CHECK_COMPLETE(); - auto lexicallyNormal = path.lexically_normal(); - Log::info("Navigating to: {}", lexicallyNormal.generic_string()); + const auto target = FileExplorer::navigationTarget(path.generic_string()); + Log::info("Navigating to: {}", target); preNavigatePath_ = currentPath_.value(); - currentPath_ = lexicallyNormal; + currentPath_ = std::filesystem::path{target}; fileEngine_->listDirectory( currentPath_.value(), [this, directory = currentPath_.value()]( diff --git a/frontend/source/frontend/file_explorer/side_model.cpp b/frontend/source/frontend/file_explorer/side_model.cpp index 029208b7..a886fb26 100644 --- a/frontend/source/frontend/file_explorer/side_model.cpp +++ b/frontend/source/frontend/file_explorer/side_model.cpp @@ -226,6 +226,18 @@ const std::vector& SideModel::items() const return items_; } +void SideModel::revertNavigation() +{ + if (currentPath_.value() != listedPath_) + { + currentPath_ = listedPath_; + currentPath_.modifyNow(); + } + // Showing the listed items again, sorted and with their selection, also ends the side's loading state. + if (refreshCallback_) + refreshCallback_(true, true); +} + void SideModel::onDirectoryListing( std::filesystem::path const& directory, std::optional> directoryEntries, @@ -251,15 +263,7 @@ void SideModel::onDirectoryListing( .text = std::move(text), .buttons = ConfirmDialog::Button::Ok, }); - // undo the navigation, an initial navigation has nowhere to go back to: - if (preNavigatePath_.empty()) - currentPath_ = preNavigatePath_; - else if (currentPath_.value() != preNavigatePath_) - { - currentPath_ = preNavigatePath_; - navigateTo(currentPath_.value()); - } - return; + return revertNavigation(); } // Servers send "." and "..", the parent link below stands for the parent instead. @@ -273,8 +277,8 @@ void SideModel::onDirectoryListing( listedPath_ = directory; std::vector items{}; - if (hasParent(directory)) - items.push_back(NuiFileExplorer::Item::parentLink(directory.parent_path(), "nui://app.example/icons/folder.svg")); + if (auto parent = parentOf(directory); parent) + items.push_back(NuiFileExplorer::Item::parentLink(*std::move(parent), "nui://app.example/icons/folder.svg")); std::transform( begin(*directoryEntries), end(*directoryEntries), From 7811af299913444dc16ffc05c551997f17724cad Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:42:10 +0200 Subject: [PATCH 07/62] Synchronize is offered only for directories with valid UTF-8 names Synchronize is offered only for directories whose names are valid UTF-8, so a sync never starts on the display name of a directory that cannot be named in a path --- nui-file-explorer/source/nui-file-explorer/side.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/nui-file-explorer/source/nui-file-explorer/side.cpp b/nui-file-explorer/source/nui-file-explorer/side.cpp index 785cc751..1e1b1a8c 100644 --- a/nui-file-explorer/source/nui-file-explorer/side.cpp +++ b/nui-file-explorer/source/nui-file-explorer/side.cpp @@ -650,9 +650,14 @@ namespace NuiFileExplorer { namespace Snc = ScriptNuiComponents; - const bool thisSideOk = clickItems.size() == 1 && clickItems[0].isDirectoryLike(); + // A directory whose name is not valid UTF-8 has no path to synchronize. + const auto isSyncRoot = [](std::vector const& items) + { + return items.size() == 1 && items[0].isDirectoryLike() && items[0].nameIsValidUtf8; + }; + const bool thisSideOk = isSyncRoot(clickItems); const auto otherSelected = impl_->otherSide->selectedItems(); - const bool otherSideOk = otherSelected.size() == 1 && otherSelected[0].isDirectoryLike(); + const bool otherSideOk = isSyncRoot(otherSelected); menuItems.push_back(Snc::PopupMenu::separator()); if (thisSideOk && otherSideOk) From 3ff08eee45faa67ae89191c3457c2a6316f8026f Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:53:29 +0200 Subject: [PATCH 08/62] Listings move entries into explorer items; one sort comparator Listings move their entries into the explorer items instead of copying them, and the explorer sorts with one comparator that reads names as string views, puts the parent link first by rotating it to the front and orders directories before files; the order is NuiFileExplorer::sortItems with native tests --- .../include/frontend/terminal/file_engine.hpp | 13 +- .../file_explorer/local_side_model.cpp | 2 +- .../file_explorer/remote_side_model.cpp | 14 +- .../frontend/file_explorer/side_model.cpp | 44 ++--- .../source/frontend/terminal/file_engine.cpp | 8 +- .../include/nui-file-explorer/item.hpp | 11 +- .../nui-file-explorer/side/item_order.hpp | 79 +++++++++ .../side/side_implementation.hpp | 151 ++---------------- .../source/nui-file-explorer/side.cpp | 4 +- 9 files changed, 142 insertions(+), 184 deletions(-) create mode 100644 nui-file-explorer/include/nui-file-explorer/side/item_order.hpp diff --git a/frontend/include/frontend/terminal/file_engine.hpp b/frontend/include/frontend/terminal/file_engine.hpp index 9d711f79..1a876993 100644 --- a/frontend/include/frontend/terminal/file_engine.hpp +++ b/frontend/include/frontend/terminal/file_engine.hpp @@ -29,9 +29,14 @@ class FileEngine std::optional release(); + /** + * @brief Lists the remote directory @p path. + * + * @param onComplete Receives the entries, or std::nullopt and the reason on failure. + */ void listDirectory( std::filesystem::path const& path, - std::function> const&, std::string const& info)> + std::function> entries, std::string const& info)> onComplete ); @@ -96,8 +101,8 @@ class FileEngine /** * @brief Loads direct children of @p parentRelKey in @p section. @p generation - * must match the most recent @ref recomputeSyncDiff reply or the server - * rejects as stale — the caller then discards. + * must match the most recent @ref recomputeSyncDiff reply, else the server + * rejects it as stale and the caller discards it. */ void loadSyncDiffChildren( Ids::SyncSessionId syncSessionId, @@ -204,7 +209,7 @@ class FileEngine std::function, std::string const& info)> onOperationCreated ); - /** @brief Upload analogue of addBulkDownload — see its docs. */ + /** @brief Upload analogue of addBulkDownload, see its docs. */ void addBulkUpload( std::vector entries, std::vector operationIds, diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index c66720d6..c4f2aa50 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -682,7 +682,7 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) if (!path) { Log::warn("Properties of '{}' use the listed entry: {}", item.name, path.error().toString()); - filePropertyDialog_->open(fallback, item.displayName()); + filePropertyDialog_->open(fallback, std::string{item.displayName()}); return; } Nui::val args = Nui::val::object(); diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index b504d45e..6d12e655 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -788,7 +788,7 @@ void RemoteSideModel::onProperties(NuiFileExplorer::Item const& item) const auto path = remotePathOf(item); filePropertyDialog_->open( static_cast(item), - path ? path->value() : item.displayName(), + path ? path->value() : std::string{item.displayName()}, item.linkTarget.value_or(std::string{}) ); } @@ -803,12 +803,14 @@ void RemoteSideModel::navigateTo(std::filesystem::path const& path) currentPath_ = std::filesystem::path{target}; fileEngine_->listDirectory( currentPath_.value(), - [this, directory = currentPath_.value()]( - std::optional> const& entries, std::string const& info + lifetime_.guarded( + [this, directory = currentPath_.value()]( + std::optional> entries, std::string const& info + ) + { + onDirectoryListing(directory, std::move(entries), info); + } ) - { - onDirectoryListing(directory, entries, info); - } ); } diff --git a/frontend/source/frontend/file_explorer/side_model.cpp b/frontend/source/frontend/file_explorer/side_model.cpp index a886fb26..d2cd126f 100644 --- a/frontend/source/frontend/file_explorer/side_model.cpp +++ b/frontend/source/frontend/file_explorer/side_model.cpp @@ -226,6 +226,20 @@ const std::vector& SideModel::items() const return items_; } +std::string SideModel::iconOf(SharedData::DirectoryEntry const& entry) const +{ + if (entry.isDirectoryLike()) + return "nui://app.example/icons/folder.svg"; + if (entry.type == SharedData::DirectoryEntry::FileType::BlockDevice) + return "nui://app.example/icons/hard-drive.svg"; + + const auto extension = std::filesystem::path{entry.name}.extension().generic_string(); + if (const auto icon = uiOptions_.fileGridExtensionIcons.find(extension); + icon != uiOptions_.fileGridExtensionIcons.end()) + return fmt::format("nui://app.example/{}", icon->second); + return "nui://app.example/icons/file.svg"; +} + void SideModel::revertNavigation() { if (currentPath_.value() != listedPath_) @@ -277,32 +291,18 @@ void SideModel::onDirectoryListing( listedPath_ = directory; std::vector items{}; + items.reserve(directoryEntries->size() + 1); if (auto parent = parentOf(directory); parent) items.push_back(NuiFileExplorer::Item::parentLink(*std::move(parent), "nui://app.example/icons/folder.svg")); - std::transform( - begin(*directoryEntries), - end(*directoryEntries), + std::ranges::transform( + *directoryEntries, std::back_inserter(items), - [this, &directory](auto const& entry) + [this, &directory](SharedData::DirectoryEntry& entry) { - auto item = NuiFileExplorer::Item{ - entry, - [&entry, this]() -> std::string - { - const auto type = static_cast(entry.type); - if (entry.isDirectoryLike()) - return "nui://app.example/icons/folder.svg"; - if (type == NuiFileExplorer::Item::Type::BlockDevice) - return "nui://app.example/icons/hard-drive.svg"; - - const auto extension = std::filesystem::path{entry.name}.extension().string(); - if (uiOptions_.fileGridExtensionIcons.contains(extension)) - return "nui://app.example/" + uiOptions_.fileGridExtensionIcons.at(extension); - - return "nui://app.example/icons/file.svg"; - }() - }; - item.fullPath = directory / entry.name; + auto fullPath = directory / entry.name; + auto icon = iconOf(entry); + auto item = NuiFileExplorer::Item{std::move(entry), std::move(icon)}; + item.fullPath = std::move(fullPath); return item; } ); diff --git a/frontend/source/frontend/terminal/file_engine.cpp b/frontend/source/frontend/terminal/file_engine.cpp index 52c2949c..a9c70639 100644 --- a/frontend/source/frontend/terminal/file_engine.cpp +++ b/frontend/source/frontend/terminal/file_engine.cpp @@ -114,7 +114,7 @@ void FileEngine::lazyOpen( void FileEngine::listDirectory( std::filesystem::path const& path, - std::function> const&, std::string const& info)> + std::function> entries, std::string const& info)> onComplete ) { @@ -151,7 +151,11 @@ void FileEngine::listDirectory( return; } - onComplete(nlohmann::json::parse(Nui::JSON::stringify(val))["entries"], "Success"); + onComplete( + nlohmann::json::parse(Nui::JSON::stringify(val))["entries"] + .get>(), + "Success" + ); }, channelId.value().value(), path.generic_string() diff --git a/nui-file-explorer/include/nui-file-explorer/item.hpp b/nui-file-explorer/include/nui-file-explorer/item.hpp index c3dc1713..70a07d20 100644 --- a/nui-file-explorer/include/nui-file-explorer/item.hpp +++ b/nui-file-explorer/include/nui-file-explorer/item.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace NuiFileExplorer { @@ -23,8 +24,8 @@ namespace NuiFileExplorer /** @brief The entry leading to the parent directory, shown as "..". It is never selected or acted on. */ bool isParentLink{false}; - explicit Item(SharedData::DirectoryEntry const& entry, std::string icon = {}) - : SharedData::DirectoryEntry{entry} + explicit Item(SharedData::DirectoryEntry entry, std::string icon = {}) + : SharedData::DirectoryEntry{std::move(entry)} , icon{std::move(icon)} {} @@ -50,14 +51,14 @@ namespace NuiFileExplorer trimmed.remove_suffix(1); const auto separator = trimmed.find_last_of("/\\"); entry.name = std::string{separator == std::string_view::npos ? trimmed : trimmed.substr(separator + 1)}; - Item item{entry}; + Item item{std::move(entry)}; item.fullPath = std::filesystem::path{std::string{trimmed}}; return item; } - std::string displayName() const + std::string_view displayName() const { - return isParentLink ? std::string{".."} : name; + return isParentLink ? std::string_view{".."} : std::string_view{name}; } }; } diff --git a/nui-file-explorer/include/nui-file-explorer/side/item_order.hpp b/nui-file-explorer/include/nui-file-explorer/side/item_order.hpp new file mode 100644 index 00000000..e895aaae --- /dev/null +++ b/nui-file-explorer/include/nui-file-explorer/side/item_order.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace NuiFileExplorer +{ + enum class SortCriterion + { + Name, + Size, + Info, + Mtime + }; + + /** + * @brief How @p lhs orders against @p rhs by @p criterion, ascending. Names compare case-insensitively. + */ + inline std::weak_ordering compareItems(Item const& lhs, Item const& rhs, SortCriterion criterion) + { + switch (criterion) + { + case SortCriterion::Name: + return Utility::Algorithm::caseInsensitiveCompare(lhs.displayName(), rhs.displayName()) <=> 0; + case SortCriterion::Size: + return lhs.size <=> rhs.size; + case SortCriterion::Info: + return lhs.lsStyleTypePermsUserGroup() <=> rhs.lsStyleTypePermsUserGroup(); + case SortCriterion::Mtime: + return lhs.mtime <=> rhs.mtime; + } + return std::weak_ordering::equivalent; + } + + /** + * @brief Sorts @p entries by @p criterion: the parent link first, then the directories, then everything else, + * each group in the order @p criterion and @p ascending give. + * + * @param itemOf Gives the @ref Item of an entry. + */ + template + void sortItems(std::vector& entries, SortCriterion criterion, bool ascending, ItemOfT const& itemOf) + { + auto first = entries.begin(); + const auto parentLink = std::ranges::find_if( + entries, + [&itemOf](EntryT const& entry) + { + return itemOf(entry).isParentLink; + } + ); + if (parentLink != entries.end()) + { + std::rotate(first, parentLink, std::next(parentLink)); + ++first; + } + + const auto directoriesEnd = std::stable_partition( + first, + entries.end(), + [&itemOf](EntryT const& entry) + { + return itemOf(entry).isDirectoryLike(); + } + ); + const auto inOrder = [&itemOf, criterion, ascending](EntryT const& lhs, EntryT const& rhs) + { + const auto order = compareItems(itemOf(lhs), itemOf(rhs), criterion); + return ascending ? order < 0 : order > 0; + }; + std::sort(first, directoriesEnd, inOrder); + std::sort(directoriesEnd, entries.end(), inOrder); + } +} 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 919013ff..e466f267 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 @@ -29,14 +30,6 @@ namespace NuiFileExplorer { class Side; - enum class SortCriterion - { - Name, - Size, - Info, - Mtime - }; - struct SideImplementation { SideSettings settings; @@ -72,7 +65,7 @@ namespace NuiFileExplorer Nui::val loadingHintTimerHandle{Nui::val::undefined()}; Nui::ListenRemover> currentPathListener{}; - // Pagination — render-only slicing. SelectionManager remains index-absolute against + // Pagination: render-only slicing. SelectionManager remains index-absolute against // the full items vector. Default pageSize is intentionally large (the user prefers // unpaginated UX); the footer hides itself when pageCount <= 1. Nui::Observed currentPage{0}; @@ -111,143 +104,17 @@ namespace NuiFileExplorer void sortItems() { const auto [criterion, ascending] = sorting.value(); - switch (criterion) - { - case SortCriterion::Name: - sortByName(ascending); - break; - case SortCriterion::Size: - sortBySize(ascending); - break; - case SortCriterion::Info: - sortByInfo(ascending); - break; - case SortCriterion::Mtime: - sortByMtime(ascending); - break; - } - } - - auto partitionItems() - { - auto& items = this->items.value(); - return std::stable_partition( - items.begin(), - items.end(), - [](auto const& lhs) + NuiFileExplorer::sortItems( + items.value(), + criterion, + ascending, + [](ItemWithInternals const& entry) -> Item const& { - return lhs.item.isDirectoryLike(); + return entry.item; } ); } - void sortByPredicate(auto const& predicate) - { - auto partitionBorder = partitionItems(); - auto& items = this->items.value(); - std::sort(items.begin(), partitionBorder, predicate); - if (partitionBorder != items.end()) - std::sort(partitionBorder, items.end(), predicate); - // The parent link leads in every order. - std::stable_partition( - items.begin(), - items.end(), - [](auto const& entry) - { - return entry.item.isParentLink; - } - ); - } - - void sortByName(bool ascending) - { - if (ascending) - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return Utility::Algorithm::caseInsensitiveCompare( - lhs.item.displayName(), - rhs.item.displayName()) < 0; - } - ); - } - else - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return Utility::Algorithm::caseInsensitiveCompare( - lhs.item.displayName(), - rhs.item.displayName()) > 0; - } - ); - } - } - void sortByInfo(bool ascending) - { - if (ascending) - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return lhs.item.lsStyleTypePermsUserGroup() < rhs.item.lsStyleTypePermsUserGroup(); - } - ); - } - else - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return lhs.item.lsStyleTypePermsUserGroup() > rhs.item.lsStyleTypePermsUserGroup(); - } - ); - } - } - void sortBySize(bool ascending) - { - if (ascending) - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return lhs.item.size < rhs.item.size; - } - ); - } - else - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return lhs.item.size > rhs.item.size; - } - ); - } - } - void sortByMtime(bool ascending) - { - if (ascending) - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return lhs.item.mtime < rhs.item.mtime; - } - ); - } - else - { - sortByPredicate( - [](auto const& lhs, auto const& rhs) - { - return lhs.item.mtime > rhs.item.mtime; - } - ); - } - } - /** * @brief Fills the dropdown menus with the current texts. */ @@ -419,7 +286,7 @@ namespace NuiFileExplorer [this](std::size_t idx) { // Keyboard navigation can land us on an item that's outside the current - // pagination slice — in that case we first jump to the page that contains + // pagination slice, in that case we first jump to the page that contains // the active index, force the render flush so the element materializes, // and only then scroll it into view. const auto pageSizeValue = std::max(1, pageSize.value()); diff --git a/nui-file-explorer/source/nui-file-explorer/side.cpp b/nui-file-explorer/source/nui-file-explorer/side.cpp index 1e1b1a8c..f8b320e5 100644 --- a/nui-file-explorer/source/nui-file-explorer/side.cpp +++ b/nui-file-explorer/source/nui-file-explorer/side.cpp @@ -602,7 +602,7 @@ namespace NuiFileExplorer if (item != nullptr) { - Nui::WebApi::Console::log("Context menu item: ", item->item.displayName()); + Nui::WebApi::Console::log("Context menu item: ", std::string{item->item.displayName()}); auto selected = selectedItems(); if (std::find_if( @@ -1087,7 +1087,7 @@ namespace NuiFileExplorer * (highlight) and applyFilter (hide non-matches) so the two modes agree on what counts as a match. */ bool matchesQuery( - std::string const& itemText, + std::string_view itemText, std::u32string const& normalizedQuery, std::vector const& queryTokens ) From 71171d201961824b064b39c3f5910e15171d2ea8 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 17:56:21 +0200 Subject: [PATCH 09/62] New items and renames check names with the rules of their own side New items and renames check the name with the rules of their own side, so a remote name may contain a backslash and a Windows name is refused for the Windows reasons, and the invalid name dialog says which rule the name breaks; both sides ask for a new item through SideModel::promptNewItem with their own texts --- .../file_explorer/local_side_model.hpp | 6 ++ .../frontend/file_explorer/name_rejection.hpp | 41 ++++++++ .../file_explorer/remote_side_model.hpp | 6 -- .../frontend/file_explorer/side_model.hpp | 10 +- .../file_explorer/local_side_model.cpp | 96 ++++++------------- .../file_explorer/remote_side_model.cpp | 15 --- .../frontend/file_explorer/side_model.cpp | 16 ++-- static/assets/languages/de_DE.yaml | 23 ++++- static/assets/languages/en_US.yaml | 27 +++++- 9 files changed, 132 insertions(+), 108 deletions(-) create mode 100644 frontend/include/frontend/file_explorer/name_rejection.hpp diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 754813dd..55b94a84 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -122,6 +122,12 @@ class LocalSideModel void enqueueSingleUpload(SharedData::BulkUploadEntry const& entry, bool allowOverwrite, bool insertRefresh); + /** + * @brief Creates the item at @p path through the backend @p function, which takes the path as @p pathArgument, + * shows the listing again on success and the failure under @p failedTitle otherwise. + */ + void createItem(char const* function, char const* pathArgument, Paths::LocalPath const& path, std::string failedTitle); + /** * @brief Deletes @p targets through the backend at once, each item by itself, and refreshes the listing. */ diff --git a/frontend/include/frontend/file_explorer/name_rejection.hpp b/frontend/include/frontend/file_explorer/name_rejection.hpp new file mode 100644 index 00000000..6c5972ee --- /dev/null +++ b/frontend/include/frontend/file_explorer/name_rejection.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace FileExplorer +{ + /** + * @brief The language key below "fileExplorer.nameRejection" that explains why a new name was refused for + * @p rejection, "Other" for a rejection no single name gets. + */ + constexpr char const* nameRejectionKey(Paths::PathRejection rejection) + { + switch (rejection) + { + case Paths::PathRejection::Empty: + return "Empty"; + case Paths::PathRejection::DotOrDotDot: + return "DotOrDotDot"; + case Paths::PathRejection::ContainsSeparator: + return "ContainsSeparator"; + case Paths::PathRejection::ContainsNul: + return "ContainsNul"; + case Paths::PathRejection::InvalidUtf8: + return "InvalidUtf8"; + case Paths::PathRejection::ReservedCharacter: + return "ReservedCharacter"; + case Paths::PathRejection::ReservedDeviceName: + return "ReservedDeviceName"; + case Paths::PathRejection::TrailingDotOrSpace: + return "TrailingDotOrSpace"; + case Paths::PathRejection::NameTooLong: + return "NameTooLong"; + case Paths::PathRejection::NotAbsolute: + case Paths::PathRejection::EscapesRoot: + case Paths::PathRejection::DevicePath: + case Paths::PathRejection::UnknownParent: + return "Other"; + } + return "Other"; + } +} diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index 09dd4292..5651e794 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -108,11 +108,6 @@ class RemoteSideModel std::expected itemPathText(NuiFileExplorer::Item const& item) const override; - /** - * @brief @p name directly below the directory the shown items were listed from. - */ - std::expected childOfCurrentPath(std::string_view name) const; - /** * @brief Queues the downloads the user accepted, a single file as a transfer of its own. */ @@ -130,7 +125,6 @@ class RemoteSideModel void enqueueDeletes(std::vector nonEmpties, std::vector filesAndEmptyDirs); - void continueDeletion(); /** * @brief Downloads @p item into a file tracking instance and opens it, with @p watch also uploads it again when * it changes. diff --git a/frontend/include/frontend/file_explorer/side_model.hpp b/frontend/include/frontend/file_explorer/side_model.hpp index f5565d17..8cd6753c 100644 --- a/frontend/include/frontend/file_explorer/side_model.hpp +++ b/frontend/include/frontend/file_explorer/side_model.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -180,14 +181,9 @@ class SideModel void navigateInto(NuiFileExplorer::Item const& item); /** - * @brief A new name is a single path segment: not empty, not "." or "..", without '/', '\\' or NUL. + * @brief Tells the user the name in @p error cannot be used and why. */ - static bool isValidItemName(std::string_view name); - - /** - * @brief Tells the user @p name cannot be used as a name. - */ - void showInvalidItemName(std::string const& name); + void showInvalidItemName(Paths::PathError const& error); /** * @brief Tells the user an action was refused because of the path in @p error. diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index c4f2aa50..b6c2edf4 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -308,14 +308,12 @@ void LocalSideModel::onNewItem(NuiFileExplorer::Item::Type type) path, language->get("localSideModel", "createDirectoryFailedTitle") ); - if (!path) - return showPathRefused(language->get("localSideModel", "createItemFailedTitle"), path.error()); - Log::info("Creating new item: {}", path->toUtf8()); - - if (type == NuiFileExplorer::Item::Type::Directory) - { - Nui::val args = Nui::val::object(); - args.set("path", path->toUtf8()); + createItem( + "RpcFilesystem::createFile", "filePath", path, language->get("localSideModel", "createFileFailedTitle") + ); + } + ); +} void LocalSideModel::createItem( char const* function, @@ -327,67 +325,35 @@ void LocalSideModel::createItem( Nui::val args = Nui::val::object(); args.set(pathArgument, path.toUtf8()); - Log::info("Directory created successfully"); - // Refresh list: - navigateTo(currentPath_.value()); - }, - args - ); - } - else if (type == NuiFileExplorer::Item::Type::Regular) + Nui::RpcClient::callWithBackChannel( + function, + lifetime_.guarded( + [this, function, failedTitle = std::move(failedTitle)](Nui::val val) + { + const auto error = [&val]() -> std::optional { - Nui::val args = Nui::val::object(); - args.set("filePath", path->toUtf8()); - - Nui::RpcClient::callWithBackChannel( - "RpcFilesystem::createFile", - [this](Nui::val val) - { - if (!val.hasOwnProperty("success")) - { - Log::error("Invalid response from RpcFilesystem::createFile"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "createFileFailedTitle"), - .text = language->get("localSideModel", "invalidResponseFromBackend"), - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } - - const auto success = val["success"].as(); - if (!success) - { - const auto error = language->translate(val["error"].as()); - Log::error("Failed to create file: {}", error); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "createFileFailedTitle"), - .text = error, - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } + if (!val.hasOwnProperty("success")) + return language->get("localSideModel", "invalidResponseFromBackend"); + if (val["success"].as()) + return std::nullopt; + return language->translate(val["error"].as()); + }(); + if (!error) + return navigateTo(currentPath_.value()); - Log::info("File created successfully"); - // Refresh list: - navigateTo(currentPath_.value()); - }, - args - ); - } - else - { - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "createItemFailedTitle"), - .text = language->get("localSideModel", "unsupportedItemType"), - .buttons = ConfirmDialog::Button::Ok, - }); - } - }} + Log::error("{} failed: {}", function, *error); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = failedTitle, + .text = *error, + .buttons = ConfirmDialog::Button::Ok, + }); + } + ), + args ); } + void LocalSideModel::onDelete(std::vector const& items) { if (items.empty()) diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 6d12e655..2773e23f 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -235,21 +235,6 @@ std::expected RemoteSideModel::itemPathText(NuiFi ); } -std::expected RemoteSideModel::childOfCurrentPath(std::string_view name) const -{ - return currentRemotePath().and_then( - [name](Paths::RemotePath const& directory) - { - return Paths::PathSegment::make(name).transform( - [&directory](Paths::PathSegment const& segment) - { - return directory / segment; - } - ); - } - ); -} - bool RemoteSideModel::isComplete() const { return localModel_ != nullptr && SideModel::isComplete(); diff --git a/frontend/source/frontend/file_explorer/side_model.cpp b/frontend/source/frontend/file_explorer/side_model.cpp index d2cd126f..6895af01 100644 --- a/frontend/source/frontend/file_explorer/side_model.cpp +++ b/frontend/source/frontend/file_explorer/side_model.cpp @@ -143,19 +143,17 @@ void SideModel::navigateInto(NuiFileExplorer::Item const& item) navigateTo(std::filesystem::path{*path}); } -bool SideModel::isValidItemName(std::string_view name) -{ - if (name.empty() || name == "." || name == "..") - return false; - return name.find_first_of(std::string_view{"/\\\0", 3}) == std::string_view::npos; -} - -void SideModel::showInvalidItemName(std::string const& name) +void SideModel::showInvalidItemName(Paths::PathError const& error) { + Log::error("Refusing the name {}", error.toString()); confirmDialog_->open({ .styleVariant = ScriptNuiComponents::StyleVariant::Danger, .headerText = language->get("fileExplorer", "invalidItemNameHeader"), - .text = fmt::format(fmt::runtime(language->get("fileExplorer", "invalidItemName")), name), + .text = fmt::format( + fmt::runtime(language->get("fileExplorer", "invalidItemName")), + error.input, + language->get("fileExplorer", "nameRejection", FileExplorer::nameRejectionKey(error.reason)) + ), .buttons = ConfirmDialog::Button::Ok, }); } diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index f43f7395..d0c53b4d 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -12,7 +12,18 @@ fileExplorer: openRiskyHeader: "Eine Datei öffnen, die Code ausführen kann?" openRiskyText: "'{}' {}. Beim Öffnen kann sie ausgeführt werden, nur tun, wenn ihrer Herkunft vertraut wird.\n\nTrotzdem öffnen?" invalidItemNameHeader: "Ungültiger Name" - invalidItemName: "'{}' kann nicht als Name verwendet werden. Ein Name darf nicht leer, '.' oder '..' sein und weder '/' noch '\\' enthalten." + invalidItemName: "'{}' kann nicht als Name verwendet werden. {}" + nameRejection: + Empty: "Ein Name darf nicht leer sein." + DotOrDotDot: "'.' und '..' stehen für das aktuelle und das übergeordnete Verzeichnis." + ContainsSeparator: "Ein Name darf kein '/' enthalten." + ContainsNul: "Ein Name darf kein NUL-Zeichen enthalten." + InvalidUtf8: "Ein Name muss gültiges UTF-8 sein." + ReservedCharacter: "Windows erlaubt die Zeichen \\ : < > \" | ? * und Steuerzeichen nicht in Namen." + ReservedDeviceName: "Windows reserviert diesen Namen für ein Gerät, auch mit Dateiendung." + TrailingDotOrSpace: "Unter Windows darf ein Name nicht mit einem Punkt oder Leerzeichen enden." + NameTooLong: "Der Name ist länger, als Windows erlaubt." + Other: "Der Name ist hier nicht erlaubt." renameTargetExistsHeader: "Eintrag existiert" renameTargetExists: "'{}' existiert bereits. Ersetzen?" existenceCheckFailedHeader: "Existenzprüfung fehlgeschlagen" @@ -88,6 +99,9 @@ remoteSideModel: newFile: "Neue Datei" enterNewFileName: "Geben Sie den Namen der neuen Datei ein" createNewFile: "Neue Datei erstellen" + createItemFailedTitle: "Erstellung fehlgeschlagen" + createDirectoryFailedTitle: "Verzeichniserstellung fehlgeschlagen" + createFileFailedTitle: "Dateierstellung fehlgeschlagen" fileGridError: "Dateiansicht Fehler (Remote Seite)" nonEmptyDirectoriesFound: "Nicht leere Verzeichnisse gefunden" nonEmptyDeleteAsk: "Das folgende Verzeichnis ist nicht leer:\n{}\nMöchten Sie es und seinen gesamten Inhalt löschen?\nDies kann nicht rückgängig gemacht werden!\nAuf eigene Gefahr fortfahren." @@ -112,6 +126,12 @@ remoteSideModel: archiveDownloadFailed: "Archiv-Download fehlgeschlagen" downloadItemsQuestion: "Elemente herunterladen?" localSideModel: + newDirectory: "Neues Verzeichnis" + enterNewDirectoryName: "Geben Sie den Namen des neuen Verzeichnisses ein" + createNewDirectory: "Neues Verzeichnis erstellen" + newFile: "Neue Datei" + enterNewFileName: "Geben Sie den Namen der neuen Datei ein" + createNewFile: "Neue Datei erstellen" fileAlreadyExistsOverwriteHeader: "Datei existiert bereits, überschreiben?" fileAlreadyExistsOverwrite: "Überschreiben dieser Datei erlauben? Beachten Sie, dass Massen-Uploads in Unterverzeichnissen dazu führen können, dass mehrere Dateien überschrieben werden." failedToEnqueueUpload: "Upload-Operation für '{}' konnte nicht erstellt werden: {}" @@ -129,7 +149,6 @@ localSideModel: uploadConfirmTitle: "Elemente hochladen?" deleteFilesFailedTitle: "Dateien löschen fehlgeschlagen" createFileFailedTitle: "Dateierstellung fehlgeschlagen" - unsupportedItemType: "Nicht unterstützter Elementtyp" createDirectoryFailedTitle: "Verzeichniserstellung fehlgeschlagen" createItemFailedTitle: "Erstellung fehlgeschlagen" failedToOpenFile: "Datei konnte nicht geöffnet werden." diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index fb32bbb0..ec9f35bc 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -12,7 +12,18 @@ fileExplorer: openRiskyHeader: "Open a file that can run code?" openRiskyText: "'{}' {}. Opening it may run it, only do this if you trust where it came from.\n\nOpen it anyway?" invalidItemNameHeader: "Invalid Name" - invalidItemName: "'{}' cannot be used as a name. A name may not be empty, '.' or '..', and may not contain '/' or '\\'." + invalidItemName: "'{}' cannot be used as a name. {}" + nameRejection: + Empty: "A name cannot be empty." + DotOrDotDot: "'.' and '..' stand for the current and the parent directory." + ContainsSeparator: "A name cannot contain '/'." + ContainsNul: "A name cannot contain a NUL character." + InvalidUtf8: "A name has to be valid UTF-8." + ReservedCharacter: "Windows does not allow the characters \\ : < > \" | ? * or control characters in names." + ReservedDeviceName: "Windows reserves this name for a device, also with an extension." + TrailingDotOrSpace: "On Windows a name cannot end with a dot or a space." + NameTooLong: "The name is longer than Windows allows." + Other: "The name is not allowed here." renameTargetExistsHeader: "Item Exists" renameTargetExists: "'{}' already exists. Replace it?" existenceCheckFailedHeader: "Existence Check Failed" @@ -182,10 +193,13 @@ sideModel: remoteSideModel: newDirectory: "New Directory" enterNewDirectoryName: "Enter the name of the new directory" - createNewDirectory: "Create a new directory" + createNewDirectory: "Create New Directory" newFile: "New File" enterNewFileName: "Enter the name of the new file" - createNewFile: "Create a new file" + createNewFile: "Create New File" + createItemFailedTitle: "Create Item Failed" + createDirectoryFailedTitle: "Create Directory Failed" + createFileFailedTitle: "Create File Failed" fileGridError: "File Grid Error (Remote Side)" nonEmptyDirectoriesFound: "Non-Empty Directories Found" nonEmptyDeleteAsk: "The following directory is not empty:\n{}\nDo you wish to delete them and all their contents?\nThis cannot be undone!\nContinue at your own risk." @@ -210,6 +224,12 @@ remoteSideModel: archiveDownloadFailed: "Archive Download Failed" downloadItemsQuestion: "Download Items?" localSideModel: + newDirectory: "New Directory" + enterNewDirectoryName: "Enter the name of the new directory" + createNewDirectory: "Create New Directory" + newFile: "New File" + enterNewFileName: "Enter the name of the new file" + createNewFile: "Create New File" fileAlreadyExistsOverwriteHeader: "File already exists, overwrite?" fileAlreadyExistsOverwrite: "Allow overwriting this file? Do note that bulk uploads in subdirectories might cause multiple files to be overwritten." failedToEnqueueUpload: "Failed to create upload operation for '{}': {}" @@ -228,7 +248,6 @@ localSideModel: uploadConfirmTitle: "Upload Items?" deleteFilesFailedTitle: "Delete Files Failed" createFileFailedTitle: "Create File Failed" - unsupportedItemType: "Unsupported item type" createDirectoryFailedTitle: "Create Directory Failed" failedToOpenFile: "Failed to open file." openInFileManagerFailed: "Failed to Open in File Manager" From df09719dd0c341164c626fe94efbb9f7d4edf69d Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:01:20 +0200 Subject: [PATCH 10/62] Delete confirmations list full paths; one local listing reply path Delete confirmations list the full path of every item on both sides, and a local listing reply reaches the explorer only through SideModel::onDirectoryListing, which drops a reply for a directory the side has left and shows a failed listing with the same dialog as the remote side --- .../file_explorer/local_side_model.cpp | 52 +++++++------------ .../file_explorer/remote_side_model.cpp | 21 ++------ static/assets/languages/de_DE.yaml | 1 - static/assets/languages/en_US.yaml | 1 - 4 files changed, 23 insertions(+), 52 deletions(-) diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index b6c2edf4..2d9553e9 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -359,18 +359,6 @@ void LocalSideModel::onDelete(std::vector const& items) if (items.empty()) return; - std::vector listItems; - listItems.reserve(items.size()); - std::ranges::transform( - items, - std::back_inserter(listItems), - [](NuiFileExplorer::Item const& item) - { - return ConfirmDialog::OpenOptions::ListElement{ - .text = item.name, .description = item.fullPath.generic_string() - }; - } - ); const bool anyDirectory = std::ranges::any_of( items, [](NuiFileExplorer::Item const& item) @@ -379,20 +367,20 @@ void LocalSideModel::onDelete(std::vector const& items) } ); // Local directories go with their content in one step, unlike remote ones there is no second question. - auto text = anyDirectory ? fmt::format( - "{}\n{}", - language->get("localSideModel", "deleteSelectedConfirm"), - language->get("localSideModel", "deleteDirectoriesWithContent") - ) - : language->get("localSideModel", "deleteSelectedConfirm"); + const auto text = anyDirectory ? fmt::format( + "{}\n{}", + language->get("localSideModel", "deleteSelectedConfirm"), + language->get("localSideModel", "deleteDirectoriesWithContent") + ) + : language->get("localSideModel", "deleteSelectedConfirm"); confirmDialog_->open({ .styleVariant = ScriptNuiComponents::StyleVariant::Warning, .headerText = language->get("localSideModel", "deleteLocalItemsQuestion"), - .text = std::move(text), + .text = text, .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, .focusButton = ConfirmDialog::Button::No, - .listItems = std::move(listItems), + .listItems = pathList(items), .onClose = lifetime_.guarded( [this, items, anyDirectory](std::optional button) { @@ -717,17 +705,14 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) lifetime_.guarded( [this, directory = currentPath_.value()](Nui::val val) { - const auto error = language->translate(val["error"].as()); - Log::error("Failed to list files: {}", error); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "failedToListFiles"), - .text = error, - .buttons = ConfirmDialog::Button::Ok, - }); - revertNavigation(); - return; - } + if (!val.hasOwnProperty("success")) + return onDirectoryListing( + directory, std::nullopt, language->get("localSideModel", "invalidResponseFromBackend") + ); + if (!val["success"].as()) + return onDirectoryListing( + directory, std::nullopt, language->translate(val["error"].as()) + ); std::vector directoryEntries; const auto files = val["files"]; @@ -736,8 +721,9 @@ void LocalSideModel::navigateTo(std::filesystem::path const& path) for (int index = 0; index < fileCount; ++index) directoryEntries.push_back(directoryEntryOf(files[index])); - onDirectoryListing(directory, std::move(directoryEntries)); - }, + onDirectoryListing(directory, std::move(directoryEntries)); + } + ), args ); } diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 2773e23f..5dea85c5 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -419,8 +419,6 @@ void RemoteSideModel::onDelete(std::vector const& items) { CHECK_COMPLETE(); - using namespace std::string_literals; - Log::info("Delete items requested: {}", items.size()); if (items.empty()) @@ -429,20 +427,9 @@ void RemoteSideModel::onDelete(std::vector const& items) return; } - const auto itemSize = items.size(); - std::string confirmText; - if (itemSize > 1) - confirmText = fmt::format(fmt::runtime(language->get("remoteSideModel", "deleteMultipleConfirm")), itemSize); - else - confirmText = fmt::format( - fmt::runtime(language->get("remoteSideModel", "deleteSingleConfirm")), items.front().name - ); - - std::vector listItems; - for (const auto& item : items) - { - listItems.push_back({item.name, item.fullPath.generic_string()}); - } + const auto confirmText = items.size() > 1 + ? fmt::format(fmt::runtime(language->get("remoteSideModel", "deleteMultipleConfirm")), items.size()) + : fmt::format(fmt::runtime(language->get("remoteSideModel", "deleteSingleConfirm")), items.front().name); confirmDialog_->open( {.styleVariant = ScriptNuiComponents::StyleVariant::Warning, @@ -450,7 +437,7 @@ void RemoteSideModel::onDelete(std::vector const& items) .text = confirmText, .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, .focusButton = ConfirmDialog::Button::No, - .listItems = listItems, + .listItems = pathList(items), .onClose = [this, items](std::optional button) { if (!button || button != ConfirmDialog::Button::Yes) diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index d0c53b4d..07ecc098 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -135,7 +135,6 @@ localSideModel: fileAlreadyExistsOverwriteHeader: "Datei existiert bereits, überschreiben?" fileAlreadyExistsOverwrite: "Überschreiben dieser Datei erlauben? Beachten Sie, dass Massen-Uploads in Unterverzeichnissen dazu führen können, dass mehrere Dateien überschrieben werden." failedToEnqueueUpload: "Upload-Operation für '{}' konnte nicht erstellt werden: {}" - failedToListFiles: "Dateien auflisten fehlgeschlagen" invalidResponseFromBackend: "Ungültige Antwort vom Backend" fileGridError: "Dateiansicht Fehler (Lokale Seite)" renameFailed: "Umbenennen fehlgeschlagen" diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index ec9f35bc..7d547dcb 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -233,7 +233,6 @@ localSideModel: fileAlreadyExistsOverwriteHeader: "File already exists, overwrite?" fileAlreadyExistsOverwrite: "Allow overwriting this file? Do note that bulk uploads in subdirectories might cause multiple files to be overwritten." failedToEnqueueUpload: "Failed to create upload operation for '{}': {}" - failedToListFiles: "Failed to list files" invalidResponseFromBackend: "Invalid response from backend" fileGridError: "File Grid Error (Local Side)" renameFailed: "Rename Failed" From cf6cc1738c6ded2bf7d8a1a68d16ffffbf45141a Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:05:21 +0200 Subject: [PATCH 11/62] Side model callbacks run only while their owner exists RPC replies and dialog answers of the side models, the file engine's channel creation, the frontend operation queue and opening a local file run only while their owner exists, so closing a session while a listing, a transfer request or a confirmation is pending no longer touches the closed session --- .../file_explorer/open_local_file.hpp | 12 +- .../session_components/operation_queue.hpp | 3 + .../include/frontend/terminal/file_engine.hpp | 3 + .../file_explorer/local_side_model.cpp | 290 +++++---- .../file_explorer/open_local_file.cpp | 36 +- .../file_explorer/remote_side_model.cpp | 605 ++++++++++-------- .../session_components/operation_queue.cpp | 191 +++--- .../source/frontend/terminal/file_engine.cpp | 6 +- 8 files changed, 639 insertions(+), 507 deletions(-) diff --git a/frontend/include/frontend/file_explorer/open_local_file.hpp b/frontend/include/frontend/file_explorer/open_local_file.hpp index 3b8e4510..ecda7303 100644 --- a/frontend/include/frontend/file_explorer/open_local_file.hpp +++ b/frontend/include/frontend/file_explorer/open_local_file.hpp @@ -2,12 +2,20 @@ #include #include +#include namespace FileExplorer { /** * @brief Opens the local file @p path with its application. A file that can run code is opened only after the - * user confirmed it, when the settings ask for that; errors are shown in @p dialog. + * user confirmed it, when the settings ask for that; errors are shown in @p dialog. Replies that arrive + * after @p owner is gone are dropped. */ - void openLocalFile(ConfirmDialog& dialog, Paths::LocalPath const& path, bool openWith); + void openLocalFile( + Utility::LifetimeToken const& owner, + ConfirmDialog& dialog, + Paths::LocalPath const& path, + bool openWith, + std::function onDeclined = {} + ); } diff --git a/frontend/include/frontend/session_components/operation_queue.hpp b/frontend/include/frontend/session_components/operation_queue.hpp index 32e16d56..c9c8db2b 100644 --- a/frontend/include/frontend/session_components/operation_queue.hpp +++ b/frontend/include/frontend/session_components/operation_queue.hpp @@ -33,6 +33,7 @@ #include #include +#include #include #include @@ -372,4 +373,6 @@ class OperationQueue private: struct Implementation; std::unique_ptr impl_; + /** @brief Drops RPC replies and dialog answers that arrive after the queue is gone. */ + Utility::LifetimeToken lifetime_{}; }; \ No newline at end of file diff --git a/frontend/include/frontend/terminal/file_engine.hpp b/frontend/include/frontend/terminal/file_engine.hpp index 1a876993..c0c952c9 100644 --- a/frontend/include/frontend/terminal/file_engine.hpp +++ b/frontend/include/frontend/terminal/file_engine.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -326,4 +327,6 @@ class FileEngine struct Implementation; std::unique_ptr impl_; Nui::MoveDetector moveDetector_; + /** @brief Drops the reply of the channel creation when this engine is gone. */ + Utility::LifetimeToken lifetime_{}; }; diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index 2d9553e9..dbb144e0 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -103,47 +103,49 @@ void LocalSideModel::probeOpenerCapabilities() { Nui::RpcClient::callWithBackChannel( "RpcFilesystem::openerCapabilities", - [this](Nui::val val) - { - if (!val.hasOwnProperty("success") || !val["success"].as()) + lifetime_.guarded( + [this](Nui::val val) { - Log::warn("RpcFilesystem::openerCapabilities failed or unavailable; assuming capable."); - return; - } + if (!val.hasOwnProperty("success") || !val["success"].as()) + { + Log::warn("RpcFilesystem::openerCapabilities failed or unavailable; assuming capable."); + return; + } - SharedData::OpenerCapabilities caps{}; - try - { - Nui::convertFromVal(val["capabilities"], caps); - } - catch (std::exception const& e) - { - Log::warn("Failed to parse openerCapabilities response: {}", e.what()); - return; - } + SharedData::OpenerCapabilities caps{}; + try + { + Nui::convertFromVal(val["capabilities"], caps); + } + catch (std::exception const& e) + { + Log::warn("Failed to parse openerCapabilities response: {}", e.what()); + return; + } - openerCaps_ = caps; - Log::info( - "Opener capabilities: canOpenFile={}, canOpenInFileManager={}, reason='{}'", - caps.canOpenFile, - caps.canOpenInFileManager, - caps.reason - ); + openerCaps_ = caps; + Log::info( + "Opener capabilities: canOpenFile={}, canOpenInFileManager={}, reason='{}'", + caps.canOpenFile, + caps.canOpenInFileManager, + caps.reason + ); - if (!caps.canOpenFile || !caps.canOpenInFileManager) - { - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Warning, - .headerText = language->get("localSideModel", "portalUnavailableHeader"), - .text = fmt::format( - fmt::runtime(language->get("localSideModel", "portalUnavailableText")), - language->translate(caps.reason) - ), - .buttons = ConfirmDialog::Button::Ok, - .neverShowAgainId = "localSideModel.portalUnavailable", - }); + if (!caps.canOpenFile || !caps.canOpenInFileManager) + { + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Warning, + .headerText = language->get("localSideModel", "portalUnavailableHeader"), + .text = fmt::format( + fmt::runtime(language->get("localSideModel", "portalUnavailableText")), + language->translate(caps.reason) + ), + .buttons = ConfirmDialog::Button::Ok, + .neverShowAgainId = "localSideModel.portalUnavailable", + }); + } } - } + ) ); } @@ -550,10 +552,12 @@ void LocalSideModel::onDropExternal( .headerText = language->get("sessionFrontend", "externalDropWebkitWarningTitle"), .text = language->get("sessionFrontend", "externalDropWebkitWarningText"), .buttons = ConfirmDialog::Button::Ok, - .onClose = [this, items, subDir](std::optional) - { - onDropExternal(items, subDir, false); - }, + .onClose = lifetime_.guarded( + [this, items, subDir](std::optional) + { + onDropExternal(items, subDir, false); + } + ), }); } else @@ -644,12 +648,13 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) Nui::RpcClient::callWithBackChannel( "RpcFilesystem::properties", - [this, fallback = std::move(fallback), displayPath = path->toUtf8()](Nui::val val) mutable - { - const auto useFallback = [this, &fallback, &displayPath]() + lifetime_.guarded( + [this, fallback = std::move(fallback), displayPath = path->toUtf8()](Nui::val val) mutable { - filePropertyDialog_->open(fallback, displayPath); - }; + const auto useFallback = [this, &fallback, &displayPath]() + { + filePropertyDialog_->open(fallback, displayPath); + }; if (!val.hasOwnProperty("success") || !val["success"].as()) { @@ -665,11 +670,12 @@ void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) const auto full = directoryEntryOf(val["entry"]); - std::string resolvedPath{}; - if (val.hasOwnProperty("resolvedPath") && val["resolvedPath"].isString()) - resolvedPath = val["resolvedPath"].as(); - filePropertyDialog_->open(full, displayPath, std::move(resolvedPath)); - }, + std::string resolvedPath{}; + if (val.hasOwnProperty("resolvedPath") && val["resolvedPath"].isString()) + resolvedPath = val["resolvedPath"].as(); + filePropertyDialog_->open(full, displayPath, std::move(resolvedPath)); + } + ), args ); } @@ -766,23 +772,27 @@ void LocalSideModel::enqueueSingleUpload( entry.source, entry.destination, entry.sizeBytes, - [this, source = entry.source](std::optional const& opId, std::string const& info) - { - if (!opId) + lifetime_.guarded( + [this, source = entry.source](std::optional const& opId, std::string const& info) { - Log::error("Failed to create upload operation: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = "", - .text = fmt::format( - fmt::runtime(language->get("localSideModel", "failedToEnqueueUpload")), source.toUtf8(), info - ), - .buttons = ConfirmDialog::Button::Ok, - }); - return; + if (!opId) + { + Log::error("Failed to create upload operation: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = "", + .text = fmt::format( + fmt::runtime(language->get("localSideModel", "failedToEnqueueUpload")), + source.toUtf8(), + info + ), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + Log::info("Upload operation created with id: {}", opId->value()); } - Log::info("Upload operation created with id: {}", opId->value()); - }, + ), allowOverwrite, insertRefresh ); @@ -895,7 +905,7 @@ void LocalSideModel::onOpen(NuiFileExplorer::Item const& item, bool openWith) if (!path) return showPathRefused(language->get("localSideModel", "failedToOpenFile"), path.error()); - FileExplorer::openLocalFile(*confirmDialog_, *path, openWith); + FileExplorer::openLocalFile(lifetime_, *confirmDialog_, *path, openWith); } void LocalSideModel::onOpenInFileManager(NuiFileExplorer::Item const& item) @@ -908,34 +918,36 @@ void LocalSideModel::onOpenInFileManager(NuiFileExplorer::Item const& item) Nui::RpcClient::callWithBackChannel( "RpcFilesystem::openInFileManager", - [this](Nui::val val) - { - if (!val.hasOwnProperty("success")) + lifetime_.guarded( + [this](Nui::val val) { - Log::error("Invalid response from RpcFilesystem::openInFileManager"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "openInFileManagerFailed"), - .text = language->get("localSideModel", "invalidResponseFromBackend"), - .buttons = ConfirmDialog::Button::Ok, - }); - return; - } + if (!val.hasOwnProperty("success")) + { + Log::error("Invalid response from RpcFilesystem::openInFileManager"); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("localSideModel", "openInFileManagerFailed"), + .text = language->get("localSideModel", "invalidResponseFromBackend"), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } - const auto success = val["success"].as(); - if (!success) - { - const auto error = language->translate(val["error"].as()); - Log::error("Failed to open in file manager: {}", error); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "openInFileManagerFailed"), - .text = error, - .buttons = ConfirmDialog::Button::Ok, - }); - return; + const auto success = val["success"].as(); + if (!success) + { + const auto error = language->translate(val["error"].as()); + Log::error("Failed to open in file manager: {}", error); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("localSideModel", "openInFileManagerFailed"), + .text = error, + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } } - }, + ), absolutePath ); } @@ -1025,56 +1037,62 @@ void LocalSideModel::onTransferAsArchive(std::vector cons .initialFileStem = defaultStem, .initialCodec = ArchiveCodec::Gzip, .initialCompressionLevel = 5, - .onConfirm = [this, paths = std::move(localPaths)](std::optional const& result) mutable - { - if (!result) + .onConfirm = lifetime_.guarded( + [this, paths = std::move(localPaths)](std::optional const& result) mutable { - Log::info("Upload as Archive: user cancelled"); - return; - } - if (!remoteModel_) - return; - - const std::string filename = result->fileStem + ".tar" + archiveCodecExtension(result->codec); - auto archivePath = remoteModel_->currentRemotePath().and_then( - [&filename](Paths::RemotePath const& directory) + if (!result) { - return Paths::PathSegment::make(filename).transform( - [&directory](Paths::PathSegment const& segment) - { - return directory / segment; - } - ); + Log::info("Upload as Archive: user cancelled"); + return; } - ); - if (!archivePath) - return showPathRefused(language->get("localSideModel", "archiveUploadFailed"), archivePath.error()); - const auto remotePath = *std::move(archivePath); - - operationQueue_->enqueueArchiveUpload( - std::move(paths), - remotePath, - backendCompressionCode(result->codec), - result->compressionLevel, - /*mayOverwrite=*/false, - SharedData::OperationMode::Queued, - [this, remotePath](std::optional const& opId, std::string const& info) - { - if (!opId) + if (!remoteModel_) + return; + + const auto filename = fmt::format("{}.tar{}", result->fileStem, archiveCodecExtension(result->codec)); + auto archivePath = remoteModel_->currentRemotePath().and_then( + [&filename](Paths::RemotePath const& directory) { - Log::error("Archive upload failed to enqueue: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("localSideModel", "archiveUploadFailed"), - .text = info, - .buttons = ConfirmDialog::Button::Ok, - }); - return; + return Paths::PathSegment::make(filename).transform( + [&directory](Paths::PathSegment const& segment) + { + return directory / segment; + } + ); } - Log::info("Archive upload queued as {} (destination {})", opId->value(), remotePath.value()); - } - ); - }, + ); + if (!archivePath) + return showPathRefused(language->get("localSideModel", "archiveUploadFailed"), archivePath.error()); + const auto remotePath = *std::move(archivePath); + + operationQueue_->enqueueArchiveUpload( + std::move(paths), + remotePath, + backendCompressionCode(result->codec), + result->compressionLevel, + /*mayOverwrite=*/false, + SharedData::OperationMode::Queued, + lifetime_.guarded( + [this, remotePath](std::optional const& opId, std::string const& info) + { + if (!opId) + { + Log::error("Archive upload failed to enqueue: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("localSideModel", "archiveUploadFailed"), + .text = info, + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + Log::info( + "Archive upload queued as {} (destination {})", opId->value(), remotePath.value() + ); + } + ) + ); + } + ), }); } diff --git a/frontend/source/frontend/file_explorer/open_local_file.cpp b/frontend/source/frontend/file_explorer/open_local_file.cpp index a202dc6d..060d9287 100644 --- a/frontend/source/frontend/file_explorer/open_local_file.cpp +++ b/frontend/source/frontend/file_explorer/open_local_file.cpp @@ -7,22 +7,32 @@ #include +#include #include namespace FileExplorer { namespace { - void requestOpen(ConfirmDialog& dialog, Paths::LocalPath const& path, bool openWith, bool confirmed) + void requestOpen( + std::weak_ptr owner, + ConfirmDialog* dialog, + Paths::LocalPath const& path, + bool openWith, + bool confirmed, + std::function onDeclined + ) { Nui::RpcClient::callWithBackChannel( "RpcFilesystem::open", - [&dialog, path, openWith](Nui::val reply) + [owner, dialog, path, openWith, onDeclined = std::move(onDeclined)](Nui::val reply) { + if (owner.expired()) + return; if (reply.hasOwnProperty("needsConfirmation") && reply["needsConfirmation"].as()) { const auto risk = language->translate(reply["risk"].as()); - dialog.open({ + dialog->open({ .styleVariant = ScriptNuiComponents::StyleVariant::Warning, .headerText = language->get("fileExplorer", "openRiskyHeader"), .text = fmt::format( @@ -31,10 +41,14 @@ namespace FileExplorer .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, .focusButton = ConfirmDialog::Button::No, .onClose = - [&dialog, path, openWith](std::optional button) + [owner, dialog, path, openWith, onDeclined](std::optional button) { + if (owner.expired()) + return; if (button == ConfirmDialog::Button::Yes) - requestOpen(dialog, path, openWith, /*confirmed=*/true); + return requestOpen(owner, dialog, path, openWith, /*confirmed=*/true, onDeclined); + if (onDeclined) + onDeclined(); }, }); return; @@ -46,7 +60,7 @@ namespace FileExplorer ? language->translate(reply["error"].as()) : language->get("localSideModel", "invalidResponseFromBackend"); Log::error("Failed to open '{}': {}", path.toUtf8(), error); - dialog.open({ + dialog->open({ .styleVariant = ScriptNuiComponents::StyleVariant::Danger, .headerText = language->get("localSideModel", "failedToOpenFile"), .text = error, @@ -60,8 +74,14 @@ namespace FileExplorer } } - void openLocalFile(ConfirmDialog& dialog, Paths::LocalPath const& path, bool openWith) + void openLocalFile( + Utility::LifetimeToken const& owner, + ConfirmDialog& dialog, + Paths::LocalPath const& path, + bool openWith, + std::function onDeclined + ) { - requestOpen(dialog, path, openWith, /*confirmed=*/false); + requestOpen(owner.watch(), &dialog, path, openWith, /*confirmed=*/false, std::move(onDeclined)); } } diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 5dea85c5..93210f49 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -51,33 +51,35 @@ RemoteSideModel::RemoteSideModel( fileEngine_->listDirectory( dirPath, - [this, onResultsAvailable = std::move(onResultsAvailable)]( - std::optional> entries, std::string const& info - ) - { - if (!entries) - { - Log::error("Failed to list files from remote side: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = "", - .text = info, - .buttons = ConfirmDialog::Button::Ok, - }); - onResultsAvailable({}); - return; - } - - std::vector listing{}; - listing.reserve(entries->size()); - for (auto&& entry : std::move(*entries)) + lifetime_.guarded( + [this, onResultsAvailable = std::move(onResultsAvailable)]( + std::optional> entries, std::string const& info + ) { - if (entry.isDirectory()) - listing.emplace_back(std::move(entry).name); + if (!entries) + { + Log::error("Failed to list files from remote side: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = "", + .text = info, + .buttons = ConfirmDialog::Button::Ok, + }); + onResultsAvailable({}); + return; + } + + std::vector listing{}; + listing.reserve(entries->size()); + for (auto&& entry : std::move(*entries)) + { + if (entry.isDirectory()) + listing.emplace_back(std::move(entry).name); + } + + onResultsAvailable(listing); } - - onResultsAvailable(listing); - } + ) ); }, 0., @@ -322,55 +324,57 @@ void RemoteSideModel::askNonEmptyDirectoryDeletions( .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No | ConfirmDialog::Button::None | ConfirmDialog::Button::All | ConfirmDialog::Button::Cancel, .focusButton = ConfirmDialog::Button::No, - .onClose = [this, - confirmedToDelete = std::move(confirmedToDelete), - nonEmpties = std::move(nonEmpties), - filesAndEmptyDirs = std::move(filesAndEmptyDirs), - currentNonEmpty, - dir](std::optional button) mutable - { - if (button && button == ConfirmDialog::Button::Yes) + .onClose = lifetime_.guarded( + [this, + confirmedToDelete = std::move(confirmedToDelete), + nonEmpties = std::move(nonEmpties), + filesAndEmptyDirs = std::move(filesAndEmptyDirs), + currentNonEmpty, + dir](std::optional button) mutable { - confirmedToDelete.push_back(dir); - askNonEmptyDirectoryDeletions( - std::move(confirmedToDelete), - std::move(nonEmpties), - std::move(filesAndEmptyDirs), - currentNonEmpty + 1 - ); - return; - } + if (button && button == ConfirmDialog::Button::Yes) + { + confirmedToDelete.push_back(dir); + askNonEmptyDirectoryDeletions( + std::move(confirmedToDelete), + std::move(nonEmpties), + std::move(filesAndEmptyDirs), + currentNonEmpty + 1 + ); + return; + } - if (button && button == ConfirmDialog::Button::All) - { - confirmedToDelete.push_back(dir); - for (; currentNonEmpty + 1 < nonEmpties.size(); ++currentNonEmpty) + if (button && button == ConfirmDialog::Button::All) { - confirmedToDelete.push_back(nonEmpties[currentNonEmpty + 1]); + confirmedToDelete.insert( + confirmedToDelete.end(), + std::make_move_iterator(std::next(nonEmpties.begin(), currentNonEmpty)), + std::make_move_iterator(nonEmpties.end()) + ); + enqueueDeletes(std::move(confirmedToDelete), std::move(filesAndEmptyDirs)); + return; } - enqueueDeletes(std::move(confirmedToDelete), std::move(filesAndEmptyDirs)); - return; - } - if (button && button == ConfirmDialog::Button::No) - { - askNonEmptyDirectoryDeletions( - std::move(confirmedToDelete), - std::move(nonEmpties), - std::move(filesAndEmptyDirs), - currentNonEmpty + 1 - ); - return; - } + if (button && button == ConfirmDialog::Button::No) + { + askNonEmptyDirectoryDeletions( + std::move(confirmedToDelete), + std::move(nonEmpties), + std::move(filesAndEmptyDirs), + currentNonEmpty + 1 + ); + return; + } - if (button && button == ConfirmDialog::Button::None) - { - enqueueDeletes(std::move(confirmedToDelete), std::move(filesAndEmptyDirs)); - return; - } + if (button && button == ConfirmDialog::Button::None) + { + enqueueDeletes(std::move(confirmedToDelete), std::move(filesAndEmptyDirs)); + return; + } - Log::info("User cancelled deletion of non-empty directories."); - }} + Log::info("User cancelled deletion of non-empty directories."); + } + )} ); } @@ -396,22 +400,24 @@ void RemoteSideModel::enqueueDeletes( /*insertRefresh*/ true, SharedData::OperationMode::Queued, /*onBulkComplete*/ {}, - [this](bool success, std::string const& info) - { - if (!success) + lifetime_.guarded( + [this](bool success, std::string const& info) { - Log::error("Bulk delete failed: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("remoteSideModel", "deleteFailed"), - .text = fmt::format( - fmt::runtime(language->get("remoteSideModel", "failedToCreateDeleteOperation")), - language->translate(info) - ), - .buttons = ConfirmDialog::Button::Ok, - }); + if (!success) + { + Log::error("Bulk delete failed: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("remoteSideModel", "deleteFailed"), + .text = fmt::format( + fmt::runtime(language->get("remoteSideModel", "failedToCreateDeleteOperation")), + language->translate(info) + ), + .buttons = ConfirmDialog::Button::Ok, + }); + } } - } + ) ); } @@ -438,68 +444,78 @@ void RemoteSideModel::onDelete(std::vector const& items) .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, .focusButton = ConfirmDialog::Button::No, .listItems = pathList(items), - .onClose = [this, items](std::optional button) - { - if (!button || button != ConfirmDialog::Button::Yes) + .onClose = lifetime_.guarded( + [this, items](std::optional button) { - Log::info("Delete items cancelled"); - return; - } - - std::vector directories; - std::vector files; - for (auto const& item : items) - { - // A name like "x/.." or ".." must not turn into another delete target. - auto path = remotePathOf(item); - if (!path) + if (!button || button != ConfirmDialog::Button::Yes) { - Log::error("Refusing to delete '{}': not an item of the current directory.", item.name); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("remoteSideModel", "deleteFailed"), - .text = fmt::format( - fmt::runtime(language->get("remoteSideModel", "failedToDeleteItems")), - item.name - ), - .buttons = ConfirmDialog::Button::Ok, - }); + Log::info("Delete items cancelled"); return; } - if (item.type == NuiFileExplorer::Item::Type::Directory) - directories.push_back(*std::move(path)); - else - files.push_back(*std::move(path)); - } - Log::info("Deleting items"); - fileEngine_->remove( - std::move(files), - std::move(directories), - [this](bool success, std::string const& info) + std::vector directories; + std::vector files; + for (auto const& item : items) { - if (!success) + // A name like "x/.." or ".." must not turn into another delete target. + auto path = remotePathOf(item); + if (!path) { - Log::error("Failed to delete files"); + Log::error("Refusing to delete '{}': not an item of the current directory.", item.name); confirmDialog_->open({ .styleVariant = ScriptNuiComponents::StyleVariant::Danger, .headerText = language->get("remoteSideModel", "deleteFailed"), .text = fmt::format( - fmt::runtime(language->get("remoteSideModel", "failedToDeleteItems")), info + fmt::runtime(language->get("remoteSideModel", "failedToDeleteItems")), item.name ), .buttons = ConfirmDialog::Button::Ok, }); return; } - // Refresh list from server: - onRefresh(); - }, - [this](std::vector filesAndEmptyDirs, std::vector nonEmpties) - { - askNonEmptyDirectoryDeletions({}, std::move(nonEmpties), std::move(filesAndEmptyDirs), 0); + if (item.type == NuiFileExplorer::Item::Type::Directory) + directories.push_back(*std::move(path)); + else + files.push_back(*std::move(path)); } - ); - }} + + Log::info("Deleting items"); + fileEngine_->remove( + std::move(files), + std::move(directories), + lifetime_.guarded( + [this](bool success, std::string const& info) + { + if (!success) + { + Log::error("Failed to delete files"); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("remoteSideModel", "deleteFailed"), + .text = fmt::format( + fmt::runtime(language->get("remoteSideModel", "failedToDeleteItems")), info + ), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + // Refresh list from server: + onRefresh(); + } + ), + lifetime_.guarded( + [this]( + std::vector filesAndEmptyDirs, + std::vector nonEmpties + ) + { + askNonEmptyDirectoryDeletions( + {}, std::move(nonEmpties), std::move(filesAndEmptyDirs), 0 + ); + } + ) + ); + } + )} ); } @@ -513,23 +529,25 @@ void RemoteSideModel::enqueueSingleDownload( entry.source, entry.destination, entry.sizeBytes, - [this](std::optional const& opId, std::string const& info) - { - if (!opId) + lifetime_.guarded( + [this](std::optional const& opId, std::string const& info) { - Log::error("Failed to create download operation: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("remoteSideModel", "downloadFailed"), - .text = fmt::format( - fmt::runtime(language->get("remoteSideModel", "failedToCreateDownloadOperation")), info - ), - .buttons = ConfirmDialog::Button::Ok, - }); - return; + if (!opId) + { + Log::error("Failed to create download operation: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("remoteSideModel", "downloadFailed"), + .text = fmt::format( + fmt::runtime(language->get("remoteSideModel", "failedToCreateDownloadOperation")), info + ), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + Log::info("Download operation created with id: {}", opId->value()); } - Log::info("Download operation created with id: {}", opId->value()); - }, + ), allowOverwrite, insertRefresh ); @@ -688,57 +706,64 @@ void RemoteSideModel::onTransferAsArchive(std::vector con .initialFileStem = defaultStem, .initialCodec = ArchiveCodec::Gzip, .initialCompressionLevel = 5, - .onConfirm = + .onConfirm = lifetime_.guarded( [this, roots = std::move(roots)](std::optional const& result) mutable - { - if (!result) { - Log::info("Download as Archive: user cancelled"); - return; - } - if (!localModel_) - return; - - const std::string filename = result->fileStem + ".tar" + archiveCodecExtension(result->codec); - auto archivePath = localModel_->currentLocalPath().and_then( - [&filename](Paths::LocalPath const& directory) + if (!result) { - return Paths::LocalPathSegment::make(filename).transform( - [&directory](Paths::LocalPathSegment const& segment) - { - return directory / segment; - } - ); + Log::info("Download as Archive: user cancelled"); + return; } - ); - if (!archivePath) - return showPathRefused(language->get("remoteSideModel", "archiveDownloadFailed"), archivePath.error()); - const auto localPath = *std::move(archivePath); - - operationQueue_->enqueueArchiveDownload( - std::move(roots), - localPath, - backendCompressionCode(result->codec), - result->compressionLevel, - /*mayOverwrite=*/false, - SharedData::OperationMode::Queued, - [this, localPath](std::optional const& opId, std::string const& info) - { - if (!opId) + if (!localModel_) + return; + + const auto filename = fmt::format("{}.tar{}", result->fileStem, archiveCodecExtension(result->codec)); + auto archivePath = localModel_->currentLocalPath().and_then( + [&filename](Paths::LocalPath const& directory) { - Log::error("Archive download failed to enqueue: {}", info); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("remoteSideModel", "archiveDownloadFailed"), - .text = info, - .buttons = ConfirmDialog::Button::Ok, - }); - return; + return Paths::LocalPathSegment::make(filename).transform( + [&directory](Paths::LocalPathSegment const& segment) + { + return directory / segment; + } + ); } - Log::info("Archive download queued as {} (destination {})", opId->value(), localPath.toUtf8()); - } - ); - }, + ); + if (!archivePath) + return showPathRefused( + language->get("remoteSideModel", "archiveDownloadFailed"), archivePath.error() + ); + const auto localPath = *std::move(archivePath); + + operationQueue_->enqueueArchiveDownload( + std::move(roots), + localPath, + backendCompressionCode(result->codec), + result->compressionLevel, + /*mayOverwrite=*/false, + SharedData::OperationMode::Queued, + lifetime_.guarded( + [this, localPath](std::optional const& opId, std::string const& info) + { + if (!opId) + { + Log::error("Archive download failed to enqueue: {}", info); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("remoteSideModel", "archiveDownloadFailed"), + .text = info, + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + Log::info( + "Archive download queued as {} (destination {})", opId->value(), localPath.toUtf8() + ); + } + ) + ); + } + ), }); } @@ -821,14 +846,24 @@ void RemoteSideModel::onDownloadAndOpen(NuiFileExplorer::Item const& item, bool lifetime_.guarded( [this, item, openWith, watch](Nui::val response) { - Log::error("FileTracking: createInstance failed"); - confirmDialog_->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("fileTrackingPanel", "instanceCreationFailed"), - .text = language->get("fileTrackingPanel", "instanceCreationFailedDesc"), - .buttons = ConfirmDialog::Button::Ok, - }); - return; + if (!response.hasOwnProperty("success") || !response["success"].as()) + { + Log::error("FileTracking: createInstance failed"); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("fileTrackingPanel", "instanceCreationFailed"), + .text = language->get("fileTrackingPanel", "instanceCreationFailedDesc"), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + onFileTrackingInstanceCreated( + item, + openWith, + watch, + Ids::makeInstanceId(response["instanceId"].as()), + response["instanceDir"].as() + ); } ) ); @@ -870,31 +905,37 @@ void RemoteSideModel::onFileTrackingInstanceCreated( *remotePath, *localPath, item.size, - [this, - instanceId, - instanceDirectory = *instanceDirectory, - remotePath = *remotePath, - localPath = *localPath, - openWith, - synchronize]( - std::optional const& opId, std::string const& info - ) - { - if (!opId) + lifetime_.guarded( + [this, + instanceId, + instanceDirectory = *instanceDirectory, + remotePath = *remotePath, + localPath = *localPath, + openWith, + synchronize](std::optional const& opId, std::string const& info) { - Log::error("FileTracking: failed to enqueue download for instance {}: {}", instanceId.value(), info); - return; - } - operationQueue_->addCompletionCallback( - *opId, - [this, instanceId, instanceDirectory, remotePath, localPath, openWith, synchronize](bool success) + if (!opId) { - onFileDownloadComplete( - instanceId, instanceDirectory, remotePath, localPath, openWith, synchronize, success + Log::error( + "FileTracking: failed to enqueue download for instance {}: {}", instanceId.value(), info ); + return; } - ); - }, + operationQueue_->addCompletionCallback( + *opId, + lifetime_.guarded( + [this, instanceId, instanceDirectory, remotePath, localPath, openWith, synchronize]( + bool success + ) + { + onFileDownloadComplete( + instanceId, instanceDirectory, remotePath, localPath, openWith, synchronize, success + ); + } + ) + ); + } + ), false, // allowOverwrite false, // insertRefresh false, // createMissingDirectories @@ -922,22 +963,26 @@ void RemoteSideModel::onFileDownloadComplete( if (!synchronize) { Log::info("FileTracking: opening {} (openWith={}, no sync)", localPath.toUtf8(), openWith); - FileExplorer::openLocalFile(*confirmDialog_, localPath, openWith); + FileExplorer::openLocalFile( + lifetime_, *confirmDialog_, localPath, openWith, discardWhenDeclined(instanceId, localPath) + ); return; } Nui::RpcClient::callWithBackChannel( "FileTracking::addWatch", - [this, instanceId, instanceDirectory, remotePath, localPath, openWith](Nui::val watchResponse) - { - if (!watchResponse.hasOwnProperty("success") || !watchResponse["success"].as()) + lifetime_.guarded( + [this, instanceId, instanceDirectory, remotePath, localPath, openWith](Nui::val watchResponse) { - Log::error("FileTracking: addWatch failed for instance {}", instanceId.value()); - return; + if (!watchResponse.hasOwnProperty("success") || !watchResponse["success"].as()) + { + Log::error("FileTracking: addWatch failed for instance {}", instanceId.value()); + return; + } + Log::info("FileTracking: addWatch succeeded for instance {}", instanceId.value()); + onFileWatchAdded(instanceId, instanceDirectory, remotePath, localPath, openWith); } - Log::info("FileTracking: addWatch succeeded for instance {}", instanceId.value()); - onFileWatchAdded(instanceId, instanceDirectory, remotePath, localPath, openWith); - }, + ), instanceId.value(), instanceDirectory.toUtf8(), true, @@ -957,7 +1002,19 @@ void RemoteSideModel::onFileWatchAdded( Log::info("FileTracking: opening {} (openWith={})", localPath.toUtf8(), openWith); - FileExplorer::openLocalFile(*confirmDialog_, localPath, openWith); + FileExplorer::openLocalFile( + lifetime_, *confirmDialog_, localPath, openWith, discardWhenDeclined(instanceId, localPath) + ); +} + +std::function +RemoteSideModel::discardWhenDeclined(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath) +{ + return [this, instanceId, localPath]() + { + if (fileTracking_ != nullptr) + fileTracking_->discard(instanceId, localPath); + }; } // --- IPlacesProvider --- @@ -984,29 +1041,31 @@ void RemoteSideModel::requestDefaultPlaces(std::functionuserDirectories( remoteHome_, - [this, home = remoteHome_]( - std::optional>> const& directories, - std::string const& - ) - { - if (home != remoteHome_) - return retryPlacesRequests(); - if (!directories || directories->empty()) - return placesFromHomeListing(); - - remotePlaces_.emplace(); - for (auto const& place : Utility::defaultPlaces) + lifetime_.guarded( + [this, home = remoteHome_]( + std::optional>> const& directories, + std::string const& + ) { - if (place.userDirectoryName.empty()) - continue; - const auto directory = std::ranges::find( - *directories, place.userDirectoryName, &std::pair::first - ); - if (directory != directories->end()) - remotePlaces_->emplace_back(std::string{place.kind}, directory->second); + if (home != remoteHome_) + return retryPlacesRequests(); + if (!directories || directories->empty()) + return placesFromHomeListing(); + + remotePlaces_.emplace(); + for (auto const& place : Utility::defaultPlaces) + { + if (place.userDirectoryName.empty()) + continue; + const auto directory = std::ranges::find( + *directories, place.userDirectoryName, &std::pair::first + ); + if (directory != directories->end()) + remotePlaces_->emplace_back(std::string{place.kind}, directory->second); + } + finishPlacesRequests(); } - finishPlacesRequests(); - } + ) ); } @@ -1014,35 +1073,37 @@ void RemoteSideModel::placesFromHomeListing() { fileEngine_->listDirectory( remoteHome_, - [this, home = remoteHome_]( - std::optional> const& entries, std::string const& info - ) - { - if (home != remoteHome_) - return retryPlacesRequests(); - if (!entries) + lifetime_.guarded( + [this, home = remoteHome_]( + std::optional> const& entries, std::string const& info + ) { - Log::warn("Failed to list the remote home directory for the default places: {}", info); - return finishPlacesRequests(); - } + if (home != remoteHome_) + return retryPlacesRequests(); + if (!entries) + { + Log::warn("Failed to list the remote home directory for the default places: {}", info); + return finishPlacesRequests(); + } - remotePlaces_.emplace(); - for (auto const& place : Utility::defaultPlaces) - { - const auto exists = std::ranges::any_of( - *entries, - [&place](SharedData::DirectoryEntry const& entry) - { - const bool isDirectory = entry.isDirectory() || - (entry.resolvedTarget != nullptr && entry.resolvedTarget->isDirectory()); - return isDirectory && entry.name == place.directoryName; - } - ); - if (exists) - remotePlaces_->emplace_back(std::string{place.kind}, remoteHome_ / place.directoryName); + remotePlaces_.emplace(); + for (auto const& place : Utility::defaultPlaces) + { + const auto exists = std::ranges::any_of( + *entries, + [&place](SharedData::DirectoryEntry const& entry) + { + const bool isDirectory = entry.isDirectory() || + (entry.resolvedTarget != nullptr && entry.resolvedTarget->isDirectory()); + return isDirectory && entry.name == place.directoryName; + } + ); + if (exists) + remotePlaces_->emplace_back(std::string{place.kind}, remoteHome_ / place.directoryName); + } + finishPlacesRequests(); } - finishPlacesRequests(); - } + ) ); } diff --git a/frontend/source/frontend/session_components/operation_queue.cpp b/frontend/source/frontend/session_components/operation_queue.cpp index ad77505b..47f9282d 100644 --- a/frontend/source/frontend/session_components/operation_queue.cpp +++ b/frontend/source/frontend/session_components/operation_queue.cpp @@ -420,20 +420,22 @@ void OperationQueue::cancelOperation(OperationCard const& operation) { Nui::RpcClient::callWithBackChannel( cancelName, - [this, operationId](SharedData::ErrorOrSuccess<> const& result) - { - if (!result) + lifetime_.guarded( + [this, operationId](SharedData::ErrorOrSuccess<> const& result) { - return Log::error( - "Failed to cancel operation id {}: {}", operationId.value(), result.error.value() - ); - } + if (!result) + { + return Log::error( + "Failed to cancel operation id {}: {}", operationId.value(), result.error.value() + ); + } - auto* operation = impl_->findOperation(operationId); - if (operation) - operation->state(SharedData::OperationState::Canceled); - Nui::globalEventContext.executeActiveEventsImmediately(); - }, + auto* operation = impl_->findOperation(operationId); + if (operation) + operation->state(SharedData::OperationState::Canceled); + Nui::globalEventContext.executeActiveEventsImmediately(); + } + ), operationId ); }; @@ -444,11 +446,13 @@ void OperationQueue::cancelOperation(OperationCard const& operation) .headerText = language->get("operationQueue", "cancelOperationHeader"), .text = language->get("operationQueue", "cancelOperationText"), .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, - .onClose = [doCancel](std::optional optButton) - { - if (optButton && *optButton == ConfirmDialog::Button::Yes) - doCancel(); - }, + .onClose = lifetime_.guarded( + [doCancel](std::optional optButton) + { + if (optButton && *optButton == ConfirmDialog::Button::Yes) + doCancel(); + } + ), }); } else @@ -653,10 +657,12 @@ void OperationQueue::activate(std::shared_ptr fileEngine, Ids::Sessi Nui::RpcClient::callWithBackChannel( fmt::format("OperationQueue::{}::isPaused", impl_->sessionId.value()), - [this](SharedData::ErrorOrSuccess const& result) - { - onIsPaused(result); - } + lifetime_.guarded( + [this](SharedData::ErrorOrSuccess const& result) + { + onIsPaused(result); + } + ) ); } @@ -1486,14 +1492,16 @@ void OperationQueue::unpause() return; Nui::RpcClient::callWithBackChannel( fmt::format("OperationQueue::{}::pauseUnpause", impl_->sessionId.value()), - [this](SharedData::ErrorOrSuccess<> const& result) - { - if (result) + lifetime_.guarded( + [this](SharedData::ErrorOrSuccess<> const& result) { - impl_->paused = false; - Nui::globalEventContext.executeActiveEventsImmediately(); + if (result) + { + impl_->paused = false; + Nui::globalEventContext.executeActiveEventsImmediately(); + } } - }, + ), false ); } @@ -1541,21 +1549,23 @@ void OperationQueue::openSyncSession( respectIgnoreFiles, recursive, ignoreHidden, - [this, syncSessionId, remoteScanId, localScanId](bool success, std::string const& info) - { - if (success) - return; - Log::error( - "Failed to open sync session (remoteId={}, localId={}): {}", - remoteScanId.value(), - localScanId.value(), - info - ); - // No scan runs, so neither phase would ever report back. - const auto routingIt = impl_->syncSessionRouting.find(syncSessionId.value()); - if (routingIt != impl_->syncSessionRouting.end() && routingIt->second.onScanFailed) - routingIt->second.onScanFailed(info); - } + lifetime_.guarded( + [this, syncSessionId, remoteScanId, localScanId](bool success, std::string const& info) + { + if (success) + return; + Log::error( + "Failed to open sync session (remoteId={}, localId={}): {}", + remoteScanId.value(), + localScanId.value(), + info + ); + // No scan runs, so neither phase would ever report back. + const auto routingIt = impl_->syncSessionRouting.find(syncSessionId.value()); + if (routingIt != impl_->syncSessionRouting.end() && routingIt->second.onScanFailed) + routingIt->second.onScanFailed(info); + } + ) ); } @@ -1720,21 +1730,23 @@ void OperationQueue::togglePause() { Nui::RpcClient::callWithBackChannel( fmt::format("OperationQueue::{}::pauseUnpause", impl_->sessionId.value()), - [this, requestToPauseUnpauseWas = !impl_->paused.value()](SharedData::ErrorOrSuccess<> const& result) - { - if (result) + lifetime_.guarded( + [this, requestToPauseUnpauseWas = !impl_->paused.value()](SharedData::ErrorOrSuccess<> const& result) { - Log::info("{} operation queue successfully", requestToPauseUnpauseWas ? "Paused" : "Unpaused"); - impl_->paused = !impl_->paused.value(); - Nui::globalEventContext.executeActiveEventsImmediately(); + if (result) + { + Log::info("{} operation queue successfully", requestToPauseUnpauseWas ? "Paused" : "Unpaused"); + impl_->paused = !impl_->paused.value(); + Nui::globalEventContext.executeActiveEventsImmediately(); + } + else + Log::error( + "Failed to {} operation queue: {}", + requestToPauseUnpauseWas ? "pause" : "unpause", + result.error.value() + ); } - else - Log::error( - "Failed to {} operation queue: {}", - requestToPauseUnpauseWas ? "pause" : "unpause", - result.error.value() - ); - }, + ), !impl_->paused.value() ); } @@ -1748,17 +1760,19 @@ void OperationQueue::askBackendToCancelAll() } Nui::RpcClient::callWithBackChannel( fmt::format("OperationQueue::{}::cancelAll", impl_->sessionId.value()), - [this](SharedData::ErrorOrSuccess<> const& result) - { - if (result) + lifetime_.guarded( + [this](SharedData::ErrorOrSuccess<> const& result) { - Log::info("Operation queue all was canceled"); - cancelAll(); - Nui::globalEventContext.executeActiveEventsImmediately(); + if (result) + { + Log::info("Operation queue all was canceled"); + cancelAll(); + Nui::globalEventContext.executeActiveEventsImmediately(); + } + else + Log::error("Failed to cancel all operation queue: {}", result.error.value()); } - else - Log::error("Failed to cancel all operation queue: {}", result.error.value()); - } + ) ); } @@ -1788,31 +1802,36 @@ void OperationQueue::changeAutoClean(bool doClean) loadState( *impl_->stateHolder, impl_->confirmDialog, - [this, name = *impl_->persistenceSessionName](bool success, Persistence::State const&) - { - if (!success) - return; + lifetime_.guarded( + [this, name = *impl_->persistenceSessionName](bool success, Persistence::State const&) + { + if (!success) + return; - auto iter = impl_->stateHolder->stateCache().sessions.find(name); - if (iter == impl_->stateHolder->stateCache().sessions.end()) - return; - iter->second.queueOptions->autoRemoveCompletedOperations = impl_->autoClean->value(); - iter->second.queueOptions->liveQueuePageSize = impl_->liveQueuePageSize; - impl_->stateHolder->save( - [this](std::optional const& error) - { - if (error) + auto iter = impl_->stateHolder->stateCache().sessions.find(name); + if (iter == impl_->stateHolder->stateCache().sessions.end()) + return; + iter->second.queueOptions->autoRemoveCompletedOperations = impl_->autoClean->value(); + iter->second.queueOptions->liveQueuePageSize = impl_->liveQueuePageSize; + impl_->stateHolder->save(lifetime_.guarded( + [this](std::optional const& error) { - impl_->confirmDialog->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("persistence", "errorSavingState"), - .text = fmt::format(fmt::runtime(language->get("persistence", "saveFailed")), language->translate(*error)), - .buttons = ConfirmDialog::Button::Ok, - }); + if (error) + { + impl_->confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("persistence", "errorSavingState"), + .text = fmt::format( + fmt::runtime(language->get("persistence", "saveFailed")), + language->translate(*error) + ), + .buttons = ConfirmDialog::Button::Ok, + }); + } } - } - ); - } + )); + } + ) ); } diff --git a/frontend/source/frontend/terminal/file_engine.cpp b/frontend/source/frontend/terminal/file_engine.cpp index a9c70639..cd4043aa 100644 --- a/frontend/source/frontend/terminal/file_engine.cpp +++ b/frontend/source/frontend/terminal/file_engine.cpp @@ -102,14 +102,14 @@ void FileEngine::lazyOpen( return; Log::info("Creating sftp channel"); - impl_->engine->createSftpChannel( - [this](auto const& id, std::string const& info) + impl_->engine->createSftpChannel(lifetime_.guarded( + [this](std::optional const& id, std::string const& info) { impl_->sftpChannelId = id; for (auto const& pendingOpen : std::exchange(impl_->pendingOpens, {})) pendingOpen(id, info); } - ); + )); } void FileEngine::listDirectory( From c0bbf80235c37a886af773a23826395fe50d7f19 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:08:56 +0200 Subject: [PATCH 12/62] Archive names use fmt, the side models use algorithms Archive names are built with fmt, the side models fill places, path suggestions and bulk delete entries with algorithms, "All" in the non-empty directory question takes the remaining directories in one insert, and the archive transfer docs describe what the dialog does --- .../file_explorer/local_side_model.hpp | 6 +-- .../file_explorer/remote_side_model.hpp | 6 +-- .../file_explorer/local_side_model.cpp | 42 ++++++++-------- .../file_explorer/remote_side_model.cpp | 48 +++++++++++++------ 4 files changed, 59 insertions(+), 43 deletions(-) diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 55b94a84..ca034259 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -58,10 +58,8 @@ class LocalSideModel void onTransfer(std::vector const& items, std::optional const& subDir) override; /** - * @brief "Transfer as Archive" entry point: opens the archive-transfer dialog - * and, on confirm, kicks off an upload of the selected items packed into a - * single tar archive on the remote side. Backend wiring is still pending; for - * now the onConfirm just logs the intent. + * @brief Asks for the name, codec and level of an archive and queues an upload of @p items packed into one + * tar archive in the listed remote directory. */ void onTransferAsArchive(std::vector const& items); std::vector diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index 5651e794..f44ad482 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -61,10 +61,8 @@ class RemoteSideModel void onTransfer(std::vector const& items, std::optional const& subDir) override; /** - * @brief "Transfer as Archive" entry point: opens the archive-transfer dialog - * and, on confirm, kicks off a download of the selected items packed into a - * single tar archive on the local side. Backend wiring is still pending; for - * now the onConfirm just logs the intent. + * @brief Asks for the name, codec and level of an archive and queues a download of @p items packed into one + * tar archive in the listed local directory. */ void onTransferAsArchive(std::vector const& items); std::vector diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index dbb144e0..a347f11f 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -174,14 +174,18 @@ namespace { std::vector entries; entries.reserve(places.size()); - for (auto const& place : places) - { - entries.push_back({ - .icon = iconForPlaceKind(place.kind), - .name = placeDisplayName(place.kind, place.name), - .path = place.path, - }); - } + std::ranges::transform( + places, + std::back_inserter(entries), + [](LocalPlace const& place) + { + return LocalSideModel::PlaceEntry{ + .icon = iconForPlaceKind(place.kind), + .name = placeDisplayName(place.kind, place.name), + .path = place.path, + }; + } + ); return entries; } @@ -266,14 +270,14 @@ void LocalSideModel::requestDrives(std::function)> } std::vector entries; const auto drives = val["drives"]; - const auto len = drives["length"].as(); - entries.reserve(len); - for (int idx = 0; idx < len; ++idx) + const auto length = drives["length"].as(); + entries.reserve(static_cast(length)); + for (int index = 0; index < length; ++index) { entries.push_back({ .icon = Ui5Icons::database(), - .name = drives[idx]["name"].as(), - .path = drives[idx]["path"].as(), + .name = drives[index]["name"].as(), + .path = drives[index]["path"].as(), }); } callback(std::move(entries)); @@ -633,10 +637,9 @@ namespace void LocalSideModel::onProperties(NuiFileExplorer::Item const& item) { - // listFiles only carries the lean field set; fetch the full entry (atime, birthtime, acl, ...) - // on demand so the dialog can display everything without inflating every directory listing. + // The listing carries fewer fields than the dialog shows, the full entry is asked for here. auto fallback = static_cast(item); - auto path = localPathOf(item); + const auto path = localPathOf(item); if (!path) { Log::warn("Properties of '{}' use the listed entry: {}", item.name, path.error().toString()); @@ -901,7 +904,7 @@ LocalSideModel::contextMenuItems(std::vector const& selec void LocalSideModel::onOpen(NuiFileExplorer::Item const& item, bool openWith) { Log::info("Open item requested: {}. Open with: {}", item.name, openWith); - auto path = localPathOf(item); + const auto path = localPathOf(item); if (!path) return showPathRefused(language->get("localSideModel", "failedToOpenFile"), path.error()); @@ -910,7 +913,7 @@ void LocalSideModel::onOpen(NuiFileExplorer::Item const& item, bool openWith) void LocalSideModel::onOpenInFileManager(NuiFileExplorer::Item const& item) { - auto path = localPathOf(item); + const auto path = localPathOf(item); if (!path) return showPathRefused(language->get("localSideModel", "openInFileManagerFailed"), path.error()); const auto absolutePath = path->toUtf8(); @@ -1016,9 +1019,6 @@ void LocalSideModel::onTransferAsArchive(std::vector cons return; } - // Files go straight in; directories are flattened on the backend via - // std::filesystem::recursive_directory_iterator inside the upload op's - // prepareInStrand step. std::vector localPaths; localPaths.reserve(items.size()); for (auto const& item : items) diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 93210f49..cf7157fc 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -383,14 +383,22 @@ void RemoteSideModel::enqueueDeletes( std::vector filesAndEmptyDirs ) { - // One bulk-delete request and card for the whole user action. Non-empty directories are scanned and deleted - // with their content by the backend (isDirectory), files and empty directories are deleted as they are. + // The backend deletes an entry marked as directory with its content. std::vector entries; entries.reserve(filesAndEmptyDirs.size() + nonEmpties.size()); - for (auto& path : filesAndEmptyDirs) - entries.push_back({.target = std::move(path), .isDirectory = false}); - for (auto& path : nonEmpties) - entries.push_back({.target = std::move(path), .isDirectory = true}); + const auto append = [&entries](std::vector& paths, bool isDirectory) + { + std::ranges::transform( + paths, + std::back_inserter(entries), + [isDirectory](Paths::RemotePath& path) + { + return SharedData::BulkDeleteEntry{.target = std::move(path), .isDirectory = isDirectory}; + } + ); + }; + append(filesAndEmptyDirs, false); + append(nonEmpties, true); if (entries.empty()) return; @@ -823,8 +831,14 @@ void RemoteSideModel::generatePathBoxSuggestions( maxSuggestions, [onResultsAvailable, path](std::vector suggestions) { - for (auto& suggestion : suggestions) - suggestion = path.parent_path() / suggestion; + std::ranges::transform( + suggestions, + suggestions.begin(), + [directory = path.parent_path()](std::filesystem::path const& suggestion) + { + return directory / suggestion; + } + ); onResultsAvailable(suggestions); } ); @@ -1130,11 +1144,17 @@ std::vector RemoteSideModel::defaultPlaceEntries() if (!remotePlaces_) return entries; - for (auto const& [kind, path] : *remotePlaces_) - { - entries.push_back( - {.icon = iconForPlaceKind(kind), .name = placeDisplayName(kind, kind), .path = path.generic_string()} - ); - } + std::ranges::transform( + *remotePlaces_, + std::back_inserter(entries), + [](std::pair const& place) + { + return PlaceEntry{ + .icon = iconForPlaceKind(place.first), + .name = placeDisplayName(place.first, place.first), + .path = place.second.generic_string(), + }; + } + ); return entries; } \ No newline at end of file From 31fdd86bf50ad50a7aa4e934e8762c0a1fa0dacd Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:41:40 +0200 Subject: [PATCH 13/62] File tracking bookkeeping lives in a hidden .nui-sftp-tracking directory The lock and metadata of a file tracking instance live in its hidden .nui-sftp-tracking directory, so a tracked remote file named metadata.json or .lock no longer collides with them; instance directories of older builds are still recognized and cleaned up by their legacy lock and metadata, which get the death timestamp mirrored, and fileStates lists directories and leaves the bookkeeping out --- .../file_tracking/instance_bookkeeping.hpp | 73 +++ .../temp_dir_instance_manager.hpp | 29 +- .../file_tracking/temp_dir_instancing.hpp | 8 +- backend/source/backend/CMakeLists.txt | 1 + .../file_tracking/instance_bookkeeping.cpp | 150 +++++++ .../temp_dir_instance_manager.cpp | 415 ++++++++---------- .../file_tracking/temp_dir_instancing.cpp | 91 ++-- 7 files changed, 477 insertions(+), 290 deletions(-) create mode 100644 backend/include/backend/file_tracking/instance_bookkeeping.hpp create mode 100644 backend/source/backend/file_tracking/instance_bookkeeping.cpp diff --git a/backend/include/backend/file_tracking/instance_bookkeeping.hpp b/backend/include/backend/file_tracking/instance_bookkeeping.hpp new file mode 100644 index 00000000..36cd76b8 --- /dev/null +++ b/backend/include/backend/file_tracking/instance_bookkeeping.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The hidden directory inside an instance directory that holds the instance lock and the metadata, so + * they never share a name with the tracked content next to them. + */ + inline constexpr std::string_view bookkeepingDirectoryName{".nui-sftp-tracking"}; + + /** + * @brief The lock file and the metadata file of one instance directory. + */ + struct BookkeepingPaths + { + std::filesystem::path lock; + std::filesystem::path metadata; + }; + + /** + * @brief Where an instance keeps its lock and metadata: inside the bookkeeping directory. + * @param instanceDirectory The instance directory. + */ + BookkeepingPaths bookkeepingPaths(std::filesystem::path const& instanceDirectory); + + /** + * @brief Where builds without the bookkeeping directory keep them: ".lock" and "metadata.json" + * directly in the instance directory. They are still read, and the metadata is mirrored into them. + * @param instanceDirectory The instance directory, named after the instance id. + */ + BookkeepingPaths legacyBookkeepingPaths(std::filesystem::path const& instanceDirectory); + + /** + * @brief Creates the bookkeeping directory of @p instanceDirectory (and the instance directory). On Windows it + * gets the hidden attribute, elsewhere the leading dot hides it. + */ + std::expected createBookkeepingDirectory(std::filesystem::path const& instanceDirectory); + + /** + * @brief Whether @p path is the bookkeeping directory of @p instanceDirectory or lies inside it. Compares + * lexically, so both paths must be spelled alike (both canonical, for one). + */ + bool isBookkeepingPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path); + + /** + * @brief The lock file of @p instanceDirectory: the one in the bookkeeping directory, else the legacy one. + * @return Nothing when neither exists, the directory is then no instance directory. + */ + std::optional existingLockFile(std::filesystem::path const& instanceDirectory); + + /** + * @brief Reads the metadata of @p instanceDirectory: the file in the bookkeeping directory, else the legacy one + * of a legacy instance (a legacy lock and no current one; elsewhere "metadata.json" is tracked content). + * @return Nothing when neither exists, an error when the file cannot be read or parsed. + */ + std::expected, std::string> + readMetadata(std::filesystem::path const& instanceDirectory); + + /** + * @brief Writes @p metadata into the bookkeeping directory, creating it when missing, and into the legacy file of + * a legacy instance when it has one, so builds that only know the legacy location read the same. + */ + std::expected + writeMetadata(std::filesystem::path const& instanceDirectory, nlohmann::json const& metadata); +} 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 32f6b008..0b8f2a65 100644 --- a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -29,7 +30,30 @@ namespace FileTracking std::filesystem::path instanceDir; }; - // ------------------------------------------------------------------------- + namespace InstanceManagerDetail + { + /** + * @brief What the reply of FileTracking::fileStates lists: every regular file below @p instanceDirectory as + * {path, size, mtime} (seconds) and every directory as {path, directory: true}, paths canonical and + * UTF-8. Symbolic links and the bookkeeping directory are left out. + */ + nlohmann::json fileStatesOf(std::filesystem::path const& instanceDirectory); + + /** + * @brief @p path as content of @p instanceDirectory that may be removed: its parent directory resolved, + * its last component kept as it is, so a link there is removed itself. + * @return An error when @p path is not below @p instanceDirectory once its parent is resolved, is the + * instance directory itself or lies in the bookkeeping directory. + */ + std::expected + removableContentPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path); + + /** + * @brief Whether @p instanceDirectory holds anything besides its bookkeeping directory. A directory that + * cannot be read counts as holding content. + */ + bool hasContent(std::filesystem::path const& instanceDirectory); + } /** * @brief Owns and tracks all TemporaryDirectoryInstance objects for a session. @@ -47,6 +71,7 @@ namespace FileTracking * | destroyInstance | instanceId: string | {success} | * | listInstances | — | {success, instances: [...]} | * | getInstanceInfo | instanceId: string | {success, instanceId, instanceDir} | + * | fileStates | instanceId: string | {success, files: [...]}, see InstanceManagerDetail::fileStatesOf | * | manualCleanup | — | {success} | * | countOrphaned | — | {success, count: int} | * | forceCleanup | — | {success, removed: int} | @@ -106,7 +131,7 @@ namespace FileTracking TemporaryDirectoryInstance* createInstance(); /** - * @brief Destroy and remove the instance with the given ID. + * @brief Remove the watches of the instance with the given ID, then destroy it. * * If the instance does not exist this is a no-op. * diff --git a/backend/include/backend/file_tracking/temp_dir_instancing.hpp b/backend/include/backend/file_tracking/temp_dir_instancing.hpp index 7b676486..e0658ca0 100644 --- a/backend/include/backend/file_tracking/temp_dir_instancing.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instancing.hpp @@ -40,8 +40,9 @@ namespace FileTracking * @brief Manages one temporary directory instance used for file-change tracking. * * On construction a UUID-named subdirectory is created under @p tempRootDir, - * an exclusive instance lock file is acquired inside it, and a metadata file is - * written. + * and an exclusive instance lock file is acquired and a metadata file written + * in its bookkeeping directory (see bookkeepingPaths()). Events inside the + * bookkeeping directory are not forwarded. * * On destruction the instance does **not** delete its directory. It writes a * "no longer alive" timestamp into the metadata file so a future scan (performed @@ -56,7 +57,8 @@ namespace FileTracking * "action": "Added" | "Modified" | "Deleted" | "Moved", * "directory": "/abs/path/to/dir/", * "filename": "changed-file.txt", - * "oldFilename": "previous-name.txt" // only populated for Moved + * "oldFilename": "previous-name.txt", // only populated for Moved + * "isDirectory": false // the changed path is a directory now * } * @endcode * diff --git a/backend/source/backend/CMakeLists.txt b/backend/source/backend/CMakeLists.txt index 7d7b8500..ff530174 100644 --- a/backend/source/backend/CMakeLists.txt +++ b/backend/source/backend/CMakeLists.txt @@ -20,6 +20,7 @@ add_library( process/process_store.cpp process/environment.cpp file_tracking/change_filter.cpp + file_tracking/instance_bookkeeping.cpp file_tracking/instance_lock.cpp file_tracking/instance_watch.cpp file_tracking/temp_dir_instancing.cpp diff --git a/backend/source/backend/file_tracking/instance_bookkeeping.cpp b/backend/source/backend/file_tracking/instance_bookkeeping.cpp new file mode 100644 index 00000000..fd93bfe7 --- /dev/null +++ b/backend/source/backend/file_tracking/instance_bookkeeping.cpp @@ -0,0 +1,150 @@ +#include +#include +#include + +#include + +#include +#include + +#ifdef _WIN32 +# include +#endif + +namespace FileTracking +{ + namespace + { + std::filesystem::path bookkeepingDirectory(std::filesystem::path const& instanceDirectory) + { + return instanceDirectory / std::filesystem::path{bookkeepingDirectoryName}; + } + + bool fileExists(std::filesystem::path const& path) + { + std::error_code statusError; + return std::filesystem::is_regular_file(path, statusError); + } + + /** + * @brief An instance directory of a build without the bookkeeping directory: only those have their lock and + * metadata next to the content, elsewhere files of those names are tracked content. + */ + bool isLegacyInstance(std::filesystem::path const& instanceDirectory) + { + return !fileExists(bookkeepingPaths(instanceDirectory).lock) && + fileExists(legacyBookkeepingPaths(instanceDirectory).lock); + } + + std::expected readJsonFile(std::filesystem::path const& path) + { + std::ifstream input{path}; + if (!input) + return std::unexpected(fmt::format("cannot open '{}'", Paths::nativeToUtf8(path))); + auto parsed = nlohmann::json::parse(input, nullptr, false); + if (parsed.is_discarded()) + return std::unexpected(fmt::format("'{}' is not valid JSON", Paths::nativeToUtf8(path))); + return parsed; + } + + std::expected writeJsonFile(std::filesystem::path const& path, nlohmann::json const& json) + { + std::ofstream output{path, std::ios::trunc}; + output << json.dump(4); + output.close(); + if (!output) + return std::unexpected(fmt::format("cannot write '{}'", Paths::nativeToUtf8(path))); + return {}; + } + } + + BookkeepingPaths bookkeepingPaths(std::filesystem::path const& instanceDirectory) + { + const auto directory = bookkeepingDirectory(instanceDirectory); + return {.lock = directory / "instance.lock", .metadata = directory / "metadata.json"}; + } + + BookkeepingPaths legacyBookkeepingPaths(std::filesystem::path const& instanceDirectory) + { + const auto instanceId = Paths::nativeToUtf8(instanceDirectory.filename()); + return { + .lock = instanceDirectory / Paths::nativeFromUtf8(fmt::format("{}.lock", instanceId)), + .metadata = instanceDirectory / "metadata.json", + }; + } + + std::expected createBookkeepingDirectory(std::filesystem::path const& instanceDirectory) + { + const auto directory = bookkeepingDirectory(instanceDirectory); + std::error_code createError; + std::filesystem::create_directories(directory, createError); + if (createError) + return std::unexpected(fmt::format( + "cannot create '{}': {}", Paths::nativeToUtf8(directory), Utility::errorMessage(createError) + )); +#ifdef _WIN32 + if (const auto attributes = GetFileAttributesW(directory.c_str()); attributes != INVALID_FILE_ATTRIBUTES) + SetFileAttributesW(directory.c_str(), attributes | FILE_ATTRIBUTE_HIDDEN); +#endif + return {}; + } + + bool isBookkeepingPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path) + { + const auto relative = path.lexically_relative(instanceDirectory); + return !relative.empty() && *relative.begin() == std::filesystem::path{bookkeepingDirectoryName}; + } + + std::optional existingLockFile(std::filesystem::path const& instanceDirectory) + { + if (auto lock = bookkeepingPaths(instanceDirectory).lock; fileExists(lock)) + return lock; + if (auto lock = legacyBookkeepingPaths(instanceDirectory).lock; fileExists(lock)) + return lock; + return std::nullopt; + } + + std::expected, std::string> + readMetadata(std::filesystem::path const& instanceDirectory) + { + const auto metadata = [&instanceDirectory]() -> std::optional + { + if (auto current = bookkeepingPaths(instanceDirectory).metadata; fileExists(current)) + return current; + if (auto legacy = legacyBookkeepingPaths(instanceDirectory).metadata; + fileExists(legacy) && isLegacyInstance(instanceDirectory)) + return legacy; + return std::nullopt; + }(); + if (!metadata) + return std::optional{}; + return readJsonFile(*metadata).transform( + [](nlohmann::json json) + { + return std::optional{std::move(json)}; + } + ); + } + + std::expected + writeMetadata(std::filesystem::path const& instanceDirectory, nlohmann::json const& metadata) + { + const bool mirrorLegacy = isLegacyInstance(instanceDirectory) && + fileExists(legacyBookkeepingPaths(instanceDirectory).metadata); + return createBookkeepingDirectory(instanceDirectory) + .and_then( + [&]() + { + return writeJsonFile(bookkeepingPaths(instanceDirectory).metadata, metadata); + } + ) + .and_then( + [&]() -> std::expected + { + if (!mirrorLegacy) + return {}; + return writeJsonFile(legacyBookkeepingPaths(instanceDirectory).metadata, metadata); + } + ); + } +} 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 448935bc..d53aab8e 100644 --- a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp @@ -1,127 +1,71 @@ #include +#include #include #include #include #include - +#include #include #include +#include #include -#include #include #include +#include namespace FileTracking { namespace { /** - * @brief Parse a metadata file, the stream is closed again on return. - * - * @param metaPath Path to the metadata.json file. - * @return The parsed json. + * @brief The subdirectories of @p tempRootDir, the candidates for instance directories. */ - nlohmann::json readMetadata(std::filesystem::path const& metaPath) + std::vector subdirectoriesOf(std::filesystem::path const& tempRootDir) { - std::ifstream metaIn{metaPath}; - nlohmann::json meta; - metaIn >> meta; - return meta; + std::vector directories; + std::error_code walkError; + for (std::filesystem::directory_iterator iter{tempRootDir, walkError}, end; !walkError && iter != end; + iter.increment(walkError)) + { + std::error_code typeError; + if (iter->is_directory(typeError) && !iter->is_symlink(typeError)) + directories.push_back(iter->path()); + } + return directories; } /** - * @brief Scan @p tempRootDir for dead instance directories and handle them. - * - * For each subdirectory whose name is not in @p skipIds: - * - If its lock file is held by another live process: skip. - * - Otherwise (dead): - * - If no death timestamp exists, write one now. - * - If a death timestamp exists and @c now - deadAt >= retentionHours, - * delete the directory recursively. - * - * @param tempRootDir Root directory to scan. - * @param skipIds Set of instance IDs whose directories must not be touched. - * @param retentionHours Minimum age a dead directory must reach before deletion. - */ - /** - * @brief Classify a single subdirectory as orphaned or not. - * - * A directory is considered orphaned if: - * - It is not in skipIds (not a live instance of this process), AND - * - Its lock file exists but is not held by any live process, OR its - * metadata already carries a @c deadAt timestamp. + * @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. * * @param dirPath Path to the candidate subdirectory. * @param skipIds IDs of live instances that must not be touched. - * @return true if the directory is orphaned, false otherwise. */ - bool isOrphanedDir( - std::filesystem::path const& dirPath, - std::vector const& skipIds - ) + bool isOrphanedDir(std::filesystem::path const& dirPath, std::vector const& skipIds) { - namespace fs = std::filesystem; - - auto const dirName = Paths::nativeToUtf8(dirPath.filename()); - for (auto const& sid : skipIds) - if (sid == dirName) - return false; - - auto const lockPath = dirPath / (dirName + ".lock"); - if (!fs::exists(lockPath)) - return false; - - if (InstanceLock::isLockedByAnother(lockPath)) + if (std::ranges::contains(skipIds, Paths::nativeToUtf8(dirPath.filename()))) return false; - - // Lock is not held — check if deadAt is already stamped - auto const metaPath = dirPath / "metadata.json"; - if (fs::exists(metaPath)) - { - try - { - std::ifstream metaIn{metaPath}; - nlohmann::json meta; - metaIn >> meta; - if (meta.contains("deadAt") && !meta["deadAt"].is_null()) - return true; // deadAt stamp present → orphaned - } - catch (...) {} - } - - // No deadAt yet but lock is free → also orphaned (dead process, no stamp written) - return true; + const auto lock = existingLockFile(dirPath); + return lock && !InstanceLock::isLockedByAnother(*lock); } /** - * @brief Count orphaned instance directories under @p tempRootDir. - * - * @param tempRootDir Root directory to scan. - * @param skipIds IDs of live instances to skip. - * @return Number of orphaned directories found. + * @brief The orphaned instance directories under @p tempRootDir, see isOrphanedDir. */ - int countOrphanedInstanceDirs( - std::filesystem::path const& tempRootDir, - std::vector const& skipIds - ) + std::vector + orphanedInstanceDirs(std::filesystem::path const& tempRootDir, std::vector const& skipIds) { - namespace fs = std::filesystem; - - if (!fs::exists(tempRootDir)) - return 0; - - int count = 0; - std::error_code iterErr; - for (auto const& entry : fs::directory_iterator(tempRootDir, iterErr)) - { - if (!entry.is_directory()) - continue; - if (isOrphanedDir(entry.path(), skipIds)) - ++count; - } - return count; + auto directories = subdirectoriesOf(tempRootDir); + std::erase_if( + directories, + [&skipIds](std::filesystem::path const& directory) + { + return !isOrphanedDir(directory, skipIds); + } + ); + return directories; } /** @@ -137,21 +81,9 @@ namespace FileTracking std::vector const& skipIds ) { - namespace fs = std::filesystem; - - if (!fs::exists(tempRootDir)) - return 0; - int removed = 0; - std::error_code iterErr; - for (auto const& entry : fs::directory_iterator(tempRootDir, iterErr)) + for (auto const& dirPath : orphanedInstanceDirs(tempRootDir, skipIds)) { - if (!entry.is_directory()) - continue; - auto const& dirPath = entry.path(); - if (!isOrphanedDir(dirPath, skipIds)) - continue; - Log::info( "forceCleanOrphanedInstanceDirs: removing orphaned instance '{}'", Paths::nativeToUtf8(dirPath.filename()) @@ -170,109 +102,61 @@ namespace FileTracking return removed; } + /** + * @brief The death timestamp of an orphaned instance, nothing when it has none or its metadata is unreadable. + */ + std::optional deadAtOf(std::optional const& meta) + { + if (!meta || !meta->contains("deadAt") || !(*meta)["deadAt"].is_number_integer()) + return std::nullopt; + return std::chrono::system_clock::from_time_t( + static_cast((*meta)["deadAt"].get()) + ); + } + + /** + * @brief Scan @p tempRootDir for dead instance directories and handle them. + * + * For each orphaned directory (see isOrphanedDir): + * - If no death timestamp exists, write one now. + * - If a death timestamp exists and @c now - deadAt >= retentionHours, + * delete the directory recursively. + * + * @param tempRootDir Root directory to scan. + * @param skipIds Set of instance IDs whose directories must not be touched. + * @param retentionHours Minimum age a dead directory must reach before deletion. + */ void scanAndCleanDeadInstanceDirs( std::filesystem::path const& tempRootDir, std::vector const& skipIds, std::chrono::hours retentionHours ) { - namespace fs = std::filesystem; - - if (!fs::exists(tempRootDir)) - return; - - auto const now = std::chrono::system_clock::now(); - - std::error_code iterErr; - for (auto const& entry : fs::directory_iterator(tempRootDir, iterErr)) + const auto now = std::chrono::system_clock::now(); + for (auto const& dirPath : orphanedInstanceDirs(tempRootDir, skipIds)) { - if (!entry.is_directory()) - continue; + const auto dirName = Paths::nativeToUtf8(dirPath.filename()); + const auto meta = readMetadata(dirPath); + if (!meta) + Log::warn("scanAndCleanDeadInstanceDirs: error reading metadata for '{}': {}", dirName, meta.error()); - auto const& dirPath = entry.path(); - auto const dirName = Paths::nativeToUtf8(dirPath.filename()); - - bool skip = false; - for (auto const& sid : skipIds) - { - if (sid == dirName) - { - skip = true; - break; - } - } - if (skip) - continue; - - auto const lockPath = dirPath / (dirName + ".lock"); - if (!fs::exists(lockPath)) - continue; - - if (InstanceLock::isLockedByAnother(lockPath)) - continue; // still alive, held by another process - - // ---- Dead instance ---- - auto const metaPath = dirPath / "metadata.json"; - - auto writeDeadAt = [&]() + const auto deadAt = deadAtOf(meta.value_or(std::nullopt)); + if (!deadAt) { - try - { - nlohmann::json meta; - if (fs::exists(metaPath)) - { - std::ifstream metaIn{metaPath}; - metaIn >> meta; - } - if (!meta.contains("instanceId")) - meta["instanceId"] = dirName; - meta["deadAt"] = - std::chrono::system_clock::to_time_t(now); - std::ofstream metaOut{metaPath}; - metaOut << meta.dump(4); - } - catch (std::exception const& exc) - { + auto stamped = meta.value_or(std::nullopt).value_or(nlohmann::json::object()); + if (!stamped.is_object()) + stamped = nlohmann::json::object(); + if (!stamped.contains("instanceId")) + stamped["instanceId"] = dirName; + stamped["deadAt"] = std::chrono::system_clock::to_time_t(now); + if (const auto written = writeMetadata(dirPath, stamped); !written) Log::warn( - "scanAndCleanDeadInstanceDirs: could not write deadAt for '{}': {}", - dirName, - Utility::errorMessage(exc) - ); - } - }; - - if (!fs::exists(metaPath)) - { - writeDeadAt(); - continue; - } - - // The metadata stream must be closed before remove_all, Windows refuses to delete open files. - std::optional deadAt; - try - { - auto const meta = readMetadata(metaPath); - if (meta.contains("deadAt") && !meta["deadAt"].is_null()) - deadAt = std::chrono::system_clock::from_time_t( - static_cast(meta["deadAt"].get()) + "scanAndCleanDeadInstanceDirs: could not write deadAt for '{}': {}", dirName, written.error() ); - } - catch (std::exception const& exc) - { - Log::warn( - "scanAndCleanDeadInstanceDirs: error reading metadata for '{}': {}", - dirName, - Utility::errorMessage(exc) - ); - } - - if (!deadAt) - { - writeDeadAt(); continue; } - auto const elapsed = std::chrono::duration_cast(now - *deadAt); + const auto elapsed = std::chrono::duration_cast(now - *deadAt); if (elapsed < retentionHours) continue; @@ -285,6 +169,100 @@ namespace FileTracking } } // namespace + namespace InstanceManagerDetail + { + nlohmann::json fileStatesOf(std::filesystem::path const& instanceDirectory) + { + nlohmann::json files = nlohmann::json::array(); + std::error_code walkError{}; + for (std::filesystem::recursive_directory_iterator iter{instanceDirectory, walkError}, end; + !walkError && iter != end; + iter.increment(walkError)) + { + std::error_code entryError{}; + if (iter->is_symlink(entryError)) + continue; + if (iter.depth() == 0 && iter->path().filename() == std::filesystem::path{bookkeepingDirectoryName}) + { + iter.disable_recursion_pending(); + continue; + } + // Canonical like the watched directory, so the paths match the change events. + const auto path = Paths::nativeToUtf8(std::filesystem::weakly_canonical(iter->path(), entryError)); + if (iter->is_directory(entryError)) + { + files.push_back({{"path", path}, {"directory", true}}); + continue; + } + if (!iter->is_regular_file(entryError)) + continue; + const auto size = iter->file_size(entryError); + const auto modified = iter->last_write_time(entryError); + if (entryError) + continue; + const auto seconds = std::chrono::duration_cast( + std::chrono::file_clock::to_sys(modified).time_since_epoch() + ); + files.push_back({ + {"path", path}, + {"size", size}, + {"mtime", seconds.count()}, + }); + } + if (walkError) + Log::warn( + "FileTracking::fileStates: walking '{}' failed: {}", + Paths::nativeToUtf8(instanceDirectory), + Utility::errorMessage(walkError) + ); + return files; + } + + std::expected + removableContentPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path) + { + const auto refused = [&path](std::string_view reason) + { + return std::unexpected{fmt::format("Cannot discard '{}': {}", Paths::nativeToUtf8(path), reason)}; + }; + const auto name = path.filename(); + if (name.empty() || name == "." || name == "..") + return refused("it names no file"); + + std::error_code canonicalError; + const auto directory = std::filesystem::weakly_canonical(instanceDirectory, canonicalError); + if (canonicalError) + return refused(Utility::errorMessage(canonicalError)); + const auto parent = std::filesystem::weakly_canonical(path.parent_path(), canonicalError); + if (canonicalError) + return refused(Utility::errorMessage(canonicalError)); + + const auto content = parent / name; + const auto relative = content.lexically_relative(directory); + if (relative.empty() || relative == "." || *relative.begin() == "..") + return refused("it is not in the instance directory"); + if (*relative.begin() == std::filesystem::path{bookkeepingDirectoryName}) + return refused("it belongs to the bookkeeping of the instance"); + return content; + } + + bool hasContent(std::filesystem::path const& instanceDirectory) + { + std::error_code walkError; + std::filesystem::directory_iterator iter{instanceDirectory, walkError}; + if (walkError) + return true; + return std::any_of( + iter, + std::filesystem::directory_iterator{}, + [](std::filesystem::directory_entry const& entry) + { + return entry.path().filename() != std::filesystem::path{bookkeepingDirectoryName}; + } + ); + } + } + // ------------------------------------------------------------------------- // TempDirInstanceManager // ------------------------------------------------------------------------- @@ -326,14 +304,12 @@ namespace FileTracking "FileTracking::destroyInstance", [this](RpcHelper::RpcOnce&& reply, std::string instanceId) { - auto iter = instances_.find(instanceId); - if (iter == instances_.end()) + if (!findInstance(instanceId)) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } - watches_.erase(instanceId); - instances_.erase(iter); + destroyInstance(instanceId); reply({{"success", true}}); } ); @@ -351,7 +327,7 @@ namespace FileTracking auto* inst = findInstance(instanceId); if (!inst) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } const auto watchedPath = Paths::LocalPath::fromUtf8(path); @@ -363,7 +339,7 @@ namespace FileTracking auto watch = inst->addWatch(watchedPath->native(), recursive, std::move(downloadTempSuffix)); if (!watch) { - reply.error("Failed to add watch on path: " + path); + reply.error(fmt::format("Failed to add watch on path: {}", path)); return; } watches_[instanceId].push_back(std::move(*watch)); @@ -394,7 +370,7 @@ namespace FileTracking auto* inst = findInstance(instanceId); if (!inst) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } reply({ @@ -405,9 +381,6 @@ namespace FileTracking } ); - // Size and modification time of every file in an instance, taken right after the download: downloads keep - // the server's modification time, so this is what the server had. File tracking compares against it - // before replacing the server's copy. registerOnStrand( "FileTracking::fileStates", [this](RpcHelper::RpcOnce&& reply, std::string instanceId) @@ -415,39 +388,10 @@ namespace FileTracking auto* inst = findInstance(instanceId); if (!inst) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } - nlohmann::json files = nlohmann::json::array(); - std::error_code walkError{}; - for (std::filesystem::recursive_directory_iterator iter{inst->instanceDir(), walkError}, end; - !walkError && iter != end; - iter.increment(walkError)) - { - std::error_code entryError{}; - if (!iter->is_regular_file(entryError) || iter->is_symlink(entryError)) - continue; - const auto size = iter->file_size(entryError); - const auto modified = iter->last_write_time(entryError); - if (entryError) - continue; - const auto seconds = std::chrono::duration_cast( - std::chrono::file_clock::to_sys(modified).time_since_epoch() - ); - // Canonical like the watched directory, so the paths match the change events. - files.push_back({ - {"path", Paths::nativeToUtf8(std::filesystem::weakly_canonical(iter->path(), entryError))}, - {"size", size}, - {"mtime", seconds.count()}, - }); - } - if (walkError) - Log::warn( - "FileTracking::fileStates: walking '{}' failed: {}", - instanceId, - Utility::errorMessage(walkError) - ); - reply({{"success", true}, {"files", std::move(files)}}); + reply({{"success", true}, {"files", InstanceManagerDetail::fileStatesOf(inst->instanceDir())}}); } ); @@ -464,7 +408,7 @@ namespace FileTracking "FileTracking::countOrphaned", [this](RpcHelper::RpcOnce&& reply) { - const int count = countOrphanedInstanceDirs(tempRootDir_, listInstanceIds()); + const auto count = orphanedInstanceDirs(tempRootDir_, listInstanceIds()).size(); reply({{"success", true}, {"count", count}}); } ); @@ -502,6 +446,7 @@ namespace FileTracking void TempDirInstanceManager::destroyInstance(std::string const& instanceId) { + watches_.erase(instanceId); instances_.erase(instanceId); } diff --git a/backend/source/backend/file_tracking/temp_dir_instancing.cpp b/backend/source/backend/file_tracking/temp_dir_instancing.cpp index a29c0088..57f80ed3 100644 --- a/backend/source/backend/file_tracking/temp_dir_instancing.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instancing.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -14,7 +15,6 @@ #include #include -#include #include #include #include @@ -24,10 +24,7 @@ namespace FileTracking namespace { /** - * @brief Prepare the instance directory. - * - * Creates all required parent directories as a side effect so that the - * result can be used immediately to construct an InstanceLock inside it. + * @brief Creates the instance directory with its bookkeeping directory, so the lock can be created in it. * * @param dir Path to create. * @return The same path that was passed in. @@ -35,12 +32,8 @@ namespace FileTracking */ std::filesystem::path prepareInstanceDir(std::filesystem::path const& dir) { - std::error_code err; - std::filesystem::create_directories(dir, err); - if (err) - throw std::runtime_error{ - fmt::format("Failed to create instance directory: {}", Utility::errorMessage(err)) - }; + if (const auto created = createBookkeepingDirectory(dir); !created) + throw std::runtime_error{fmt::format("Failed to create instance directory: {}", created.error())}; return dir; } @@ -62,23 +55,26 @@ namespace FileTracking } } // namespace - // ------------------------------------------------------------------------- - // FileChangeListener — efsw callback, runs on efsw's internal thread - // ------------------------------------------------------------------------- + /** + * @brief Receives the efsw events of one instance on efsw's thread and forwards them to the frontend. + */ class FileChangeListener : public efsw::FileWatchListener { public: + /** + * @param watchedInstanceDirectory The instance directory spelled like the watch reports it (canonical). + */ FileChangeListener( - std::string instanceId, + std::string const& instanceId, + std::filesystem::path watchedInstanceDirectory, boost::asio::strand strand, Nui::RpcHub* hub, std::string downloadTempSuffix ) - : instanceId_{std::move(instanceId)} + : watchedInstanceDirectory_{std::move(watchedInstanceDirectory)} , strand_{std::move(strand)} , hub_{hub} - , lockFileName_{instanceId_ + ".lock"} - , eventName_{"FileTracking::" + instanceId_ + "::onFileChanged"} + , eventName_{fmt::format("FileTracking::{}::onFileChanged", instanceId)} , downloadTempSuffix_{Persistence::effectiveTempFileSuffix(downloadTempSuffix)} {} @@ -90,14 +86,13 @@ namespace FileTracking std::string oldFilename ) override { - if (filename == lockFileName_ || filename == "metadata.json") + // efsw reports UTF-8 on every platform. + const auto changedPath = Paths::nativeFromUtf8(dir) / Paths::nativeFromUtf8(filename); + if (isBookkeepingPath(watchedInstanceDirectory_, changedPath)) return; std::error_code statusError; - // efsw reports UTF-8 on every platform. - const bool isDirectory = std::filesystem::is_directory( - Paths::nativeFromUtf8(dir) / Paths::nativeFromUtf8(filename), statusError - ); + const bool isDirectory = std::filesystem::is_directory(changedPath, statusError); const auto change = filterFileChange( {toFileAction(action), filename, std::move(oldFilename)}, isDirectory, downloadTempSuffix_ ); @@ -109,6 +104,7 @@ namespace FileTracking {"directory", Paths::nativeToUtf8(Paths::nativeFromUtf8(dir))}, {"filename", Paths::nativeToUtf8(Paths::nativeFromUtf8(change->filename))}, {"oldFilename", Paths::nativeToUtf8(Paths::nativeFromUtf8(change->oldFilename))}, + {"isDirectory", isDirectory}, }; boost::asio::post( @@ -121,10 +117,9 @@ namespace FileTracking } private: - std::string instanceId_; + std::filesystem::path watchedInstanceDirectory_; boost::asio::strand strand_; Nui::RpcHub* hub_; - std::string lockFileName_; std::string eventName_; std::string downloadTempSuffix_; }; @@ -159,7 +154,7 @@ namespace FileTracking : config{std::move(cfg)} , instanceId{std::move(instId)} , instanceDir{prepareInstanceDir(config.tempRootDir / instanceId)} - , lock{std::in_place, instanceDir / (instanceId + ".lock")} + , lock{std::in_place, bookkeepingPaths(instanceDir).lock} , strand{std::move(strd)} , hub{hubPtr} , watcher{std::make_shared()} @@ -241,7 +236,11 @@ namespace FileTracking } impl_->listener = std::make_unique( - impl_->instanceId, impl_->strand, impl_->hub, std::move(downloadTempSuffix) + impl_->instanceId, + fs::weakly_canonical(impl_->instanceDir), + impl_->strand, + impl_->hub, + std::move(downloadTempSuffix) ); // efsw takes UTF-8, a narrow string is the ANSI code page on Windows. @@ -286,36 +285,28 @@ namespace FileTracking void TemporaryDirectoryInstance::writeMetadata() { - auto const metaPath = impl_->instanceDir / "metadata.json"; - nlohmann::json meta = { + const nlohmann::json meta = { {"instanceId", impl_->instanceId}, {"createdAt", std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())}, {"deadAt", nullptr}, }; - std::ofstream out{metaPath}; - out << meta.dump(4); + if (const auto written = FileTracking::writeMetadata(impl_->instanceDir, meta); !written) + Log::error("writeMetadata: failed for instance '{}': {}", impl_->instanceId, written.error()); } void TemporaryDirectoryInstance::writeDeadTimestamp() { - auto const metaPath = impl_->instanceDir / "metadata.json"; - try - { - nlohmann::json meta; - if (std::filesystem::exists(metaPath)) - { - std::ifstream metaIn{metaPath}; - metaIn >> meta; - } - meta["deadAt"] = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); - std::ofstream out{metaPath}; - out << meta.dump(4); - } - catch (std::exception const& exc) - { - Log::error( - "writeDeadTimestamp: failed for instance '{}': {}", impl_->instanceId, Utility::errorMessage(exc) - ); - } + 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); + } + ); + if (!written) + Log::error("writeDeadTimestamp: failed for instance '{}': {}", impl_->instanceId, written.error()); } } From 65d5231d96f1d80f25295f556ba76592021ca1f3 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:41:40 +0200 Subject: [PATCH 14/62] An efsw watch handle no longer keeps its instance's watcher alive An efsw watch handle no longer keeps the watcher of its instance alive, so destroying or cleaning up an instance stops its watcher before the listener goes away, and assigning a watch handle removes the watch it held --- .../backend/file_tracking/instance_watch.hpp | 12 ++++++------ .../backend/file_tracking/instance_watch.cpp | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/backend/include/backend/file_tracking/instance_watch.hpp b/backend/include/backend/file_tracking/instance_watch.hpp index f748ff82..ec716c5c 100644 --- a/backend/include/backend/file_tracking/instance_watch.hpp +++ b/backend/include/backend/file_tracking/instance_watch.hpp @@ -10,9 +10,10 @@ namespace FileTracking /** * @brief Move-only RAII handle representing one efsw directory watch. * - * Destroying or moving-from an InstanceWatch automatically removes the - * underlying efsw watch via efsw::FileWatcher::removeWatch(). - * Instances of this type are produced by + * Destroying an InstanceWatch or assigning another one to it removes the + * underlying efsw watch via efsw::FileWatcher::removeWatch(). It does not keep + * the watcher alive: once its instance destroyed the watcher there is nothing + * left to remove. Instances of this type are produced by * TemporaryDirectoryInstance::addWatch(). */ class InstanceWatch @@ -26,13 +27,12 @@ namespace FileTracking /** * @brief Construct a valid watch handle. * - * @param watcher Shared owning pointer to the efsw::FileWatcher that - * registered this watch. + * @param watcher The efsw::FileWatcher that registered this watch. * @param watchId The watch ID returned by efsw::FileWatcher::addWatch(). * @param path The directory path being watched. */ InstanceWatch( - std::shared_ptr watcher, + std::weak_ptr watcher, efsw::WatchID watchId, std::filesystem::path path ); diff --git a/backend/source/backend/file_tracking/instance_watch.cpp b/backend/source/backend/file_tracking/instance_watch.cpp index 22848785..842069f8 100644 --- a/backend/source/backend/file_tracking/instance_watch.cpp +++ b/backend/source/backend/file_tracking/instance_watch.cpp @@ -6,7 +6,7 @@ namespace FileTracking { struct InstanceWatch::Implementation { - std::shared_ptr watcher; + std::weak_ptr watcher; efsw::WatchID watchId; std::filesystem::path path; }; @@ -14,7 +14,7 @@ namespace FileTracking InstanceWatch::InstanceWatch() = default; InstanceWatch::InstanceWatch( - std::shared_ptr watcher, + std::weak_ptr watcher, efsw::WatchID watchId, std::filesystem::path path ) @@ -31,7 +31,15 @@ namespace FileTracking } InstanceWatch::InstanceWatch(InstanceWatch&&) noexcept = default; - InstanceWatch& InstanceWatch::operator=(InstanceWatch&&) noexcept = default; + InstanceWatch& InstanceWatch::operator=(InstanceWatch&& other) noexcept + { + if (this != &other) + { + release(); + impl_ = std::move(other.impl_); + } + return *this; + } std::filesystem::path const& InstanceWatch::path() const { @@ -57,8 +65,8 @@ namespace FileTracking { if (!impl_) return; - if (impl_->watcher && impl_->watchId >= 0) - impl_->watcher->removeWatch(impl_->watchId); + if (const auto watcher = impl_->watcher.lock(); watcher && impl_->watchId >= 0) + watcher->removeWatch(impl_->watchId); impl_.reset(); } } From b73eed5b33fbb7f791763153b72e3c298b8718e4 Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:44:35 +0200 Subject: [PATCH 15/62] File tracking bookkeeping gets pure, natively tested helpers File tracking has pure, natively tested helpers for its bookkeeping: serverChanged compares a known server state with the current one, remotePathOfChange maps a local change, meaningOfMove tells a rename from a save through a temporary file, KnownServerContent holds what the server has and PendingUploads keeps one upload per path and holds back changes below a directory being uploaded --- .../file_tracking_state.hpp | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 frontend/include/frontend/session_components/file_tracking_state.hpp diff --git a/frontend/include/frontend/session_components/file_tracking_state.hpp b/frontend/include/frontend/session_components/file_tracking_state.hpp new file mode 100644 index 00000000..8bb7e00f --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking_state.hpp @@ -0,0 +1,382 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief Size and modification time (seconds) of a file on the server. + */ + struct ServerState + { + std::uint64_t mtime{0}; + std::uint64_t size{0}; + + friend bool operator==(ServerState const&, ServerState const&) = default; + }; + + /** + * @brief Whether the server's copy of a file changed since @p known was taken. A file the server has but nobody + * saw here, a vanished one and one with another size or time all changed. + * @param known What the server had when it was last seen, nothing when it had no file there. + * @param current What the server has now, nothing when it has no file there. + */ + inline bool serverChanged(std::optional const& known, std::optional const& current) + { + if (!known) + return current.has_value(); + return !current || *current != *known; + } + + /** + * @brief The remote path a change at @p changed maps to. The instance directory stands for the directory of + * @p remotePath, a change outside it or of the instance directory itself maps to nothing. + */ + inline std::expected remotePathOfChange( + Paths::LocalPath const& changed, + Paths::LocalPath const& instanceDirectory, + Paths::RemotePath const& remotePath + ) + { + const auto remoteDirectory = remotePath.parent(); + if (!remoteDirectory) + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::EscapesRoot, .input = remotePath.value()} + ); + return changed.relativeTo(instanceDirectory) + .and_then( + [&changed, &remoteDirectory]( + Paths::RelativePath const& relative + ) -> std::expected + { + if (relative.empty()) + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::Empty, .input = changed.toUtf8()} + ); + return *remoteDirectory / relative; + } + ); + } + + /** + * @brief What a move inside a tracked item does on the server. + */ + enum class MoveMeaning + { + /** @brief The server's copy of the source is renamed. */ + Rename, + /** + * @brief The destination's content changed and the source is gone: an editor saving through a temporary + * file, or a move over an existing file. A rename on the server would fail on the existing + * destination or find no source. + */ + ReplaceDestination, + }; + + /** + * @brief What a move means when the server has the destination (@p destinationOnServer) or the source + * (@p sourceOnServer), as far as file tracking knows. + */ + inline MoveMeaning meaningOfMove(bool destinationOnServer, bool sourceOnServer) + { + return destinationOnServer || !sourceOnServer ? MoveMeaning::ReplaceDestination : MoveMeaning::Rename; + } + + /** + * @brief What the server is known to have of one tracked item, by local path: what the download brought and + * what was uploaded since. + */ + class KnownServerContent + { + public: + void rememberFile(Paths::LocalPath const& path, ServerState state) + { + files_.insert_or_assign(path, state); + } + + void rememberDirectory(Paths::LocalPath const& path) + { + directories_.insert(path); + } + + /** + * @brief The known state of the file at @p path, nothing when no file is known there. + */ + std::optional fileState(Paths::LocalPath const& path) const + { + const auto found = files_.find(path); + if (found == files_.end()) + return std::nullopt; + return found->second; + } + + /** + * @brief Whether the server is known to have @p path or anything below it. + */ + bool has(Paths::LocalPath const& path) const + { + return files_.contains(path) || directories_.contains(path) || hasBelow(path); + } + + /** + * @brief Whether the server is known to have anything below @p path. + */ + bool hasBelow(Paths::LocalPath const& path) const + { + const auto isBelow = [&path](Paths::LocalPath const& candidate) + { + return candidate != path && candidate.isWithin(path); + }; + return std::ranges::any_of(std::views::keys(files_), isBelow) || std::ranges::any_of(directories_, isBelow); + } + + /** + * @brief Forgets @p path and everything below it. + */ + void forget(Paths::LocalPath const& path) + { + std::erase_if( + files_, + [&path](auto const& file) + { + return file.first.isWithin(path); + } + ); + std::erase_if( + directories_, + [&path](Paths::LocalPath const& directory) + { + return directory.isWithin(path); + } + ); + } + + /** + * @brief Moves what is known at @p from and below it to @p to, after the server renamed it. + */ + void move(Paths::LocalPath const& from, Paths::LocalPath const& to) + { + const auto moved = [&from, &to](Paths::LocalPath const& path) -> std::optional + { + return path.relativeTo(from) + .and_then( + [&to](Paths::RelativePath const& relative) + { + return to.join(relative); + } + ) + .transform( + [](Paths::LocalPath moved) + { + return std::optional{std::move(moved)}; + } + ) + .value_or(std::nullopt); + }; + std::map files; + for (auto const& [path, state] : files_) + files.insert_or_assign(moved(path).value_or(path), state); + std::set directories; + std::ranges::transform( + directories_, + std::inserter(directories, directories.end()), + [&moved](Paths::LocalPath const& path) + { + return moved(path).value_or(path); + } + ); + files_ = std::move(files); + directories_ = std::move(directories); + } + + private: + std::map files_; + std::set directories_; + }; + + /** + * @brief A changed path of a tracked item. + */ + struct LocalChange + { + Paths::LocalPath path; + bool isDirectory{false}; + + friend bool operator==(LocalChange const&, LocalChange const&) = default; + }; + + /** + * @brief The uploads of one tracked item that are on their way, from the change until the server's state after + * the upload is known. One path has at most one, and nothing below a directory being uploaded starts its + * own, so two uploads never write the same remote file at once. + */ + class PendingUploads + { + public: + /** + * @brief What became of the uploads of a path when its upload ended. + */ + struct Finished + { + /** @brief The path changed again meanwhile and has to go once more. */ + bool changedAgain{false}; + /** @brief The path was deleted or moved away meanwhile, its server copy has to go. */ + bool removeRemote{false}; + /** @brief Changes below the uploaded directory that arrived meanwhile, in arrival order. */ + std::vector changedBelow{}; + }; + + /** + * @brief Starts the upload of @p change unless one of its path or of a directory above it is on its way, the + * change then goes again once that one finished. + * @return Whether the upload starts. + */ + bool start(LocalChange const& change) + { + if (const auto found = pending_.find(change.path); found != pending_.end()) + { + found->second.changedAgain = true; + found->second.removeRemote = false; + return false; + } + const auto directoryAbove = std::ranges::find_if( + pending_, + [&change](auto const& pending) + { + return pending.second.isDirectory && change.path.isWithin(pending.first); + } + ); + if (directoryAbove != pending_.end()) + { + if (!std::ranges::contains(directoryAbove->second.changedBelow, change)) + directoryAbove->second.changedBelow.push_back(change); + return false; + } + pending_.emplace(change.path, Pending{.isDirectory = change.isDirectory}); + return true; + } + + /** + * @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. + */ + void changesHandled(Paths::LocalPath const& path) + { + if (const auto found = pending_.find(path); found != pending_.end()) + { + found->second.changedAgain = false; + found->second.changedBelow.clear(); + } + } + + /** + * @brief Whether an upload of @p path is on its way. + */ + bool isPending(Paths::LocalPath const& path) const + { + return pending_.contains(path); + } + + /** + * @brief The path of a pending upload was deleted or moved away: its server copy goes once the upload + * ended, and it does not go again. + * @return Whether an upload of @p path is on its way. + */ + bool removeRemoteWhenDone(Paths::LocalPath const& path) + { + const auto found = pending_.find(path); + if (found == pending_.end()) + return false; + found->second.removeRemote = true; + found->second.changedAgain = false; + return true; + } + + /** + * @brief Ends the upload of @p path. + */ + Finished finish(Paths::LocalPath const& path) + { + const auto found = pending_.find(path); + if (found == pending_.end()) + return {}; + auto finished = Finished{ + .changedAgain = found->second.changedAgain, + .removeRemote = found->second.removeRemote, + .changedBelow = std::move(found->second.changedBelow), + }; + pending_.erase(found); + return finished; + } + + bool empty() const + { + return pending_.empty(); + } + + private: + struct Pending + { + bool isDirectory{false}; + bool changedAgain{false}; + bool removeRemote{false}; + std::vector changedBelow{}; + }; + std::map pending_; + }; + + /** + * @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_{}; + }; +} From f3bf350bba63d7a1a76dd1cc876dfca8dd11832c Mon Sep 17 00:00:00 2001 From: 5cript Date: Thu, 15 Oct 2026 18:51:08 +0200 Subject: [PATCH 16/62] A replaced confirm dialog runs its onClose without a button A confirm dialog that another one replaces runs its onClose without a button before the new one shows, so every caller hears exactly once how its dialog ended --- .../include/frontend/dialog/confirm_dialog.hpp | 4 ++++ .../source/frontend/dialog/confirm_dialog.cpp | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/include/frontend/dialog/confirm_dialog.hpp b/frontend/include/frontend/dialog/confirm_dialog.hpp index 65b26c84..a8db3a48 100644 --- a/frontend/include/frontend/dialog/confirm_dialog.hpp +++ b/frontend/include/frontend/dialog/confirm_dialog.hpp @@ -54,6 +54,10 @@ class ConfirmDialog std::function buttonPressed)> onClose = [](auto) {}; }; + /** + * @brief Shows the dialog. A dialog still on screen is replaced, its onClose runs first without a button, so + * every onClose runs exactly once. + */ void open(OpenOptions const& options); private: diff --git a/frontend/source/frontend/dialog/confirm_dialog.cpp b/frontend/source/frontend/dialog/confirm_dialog.cpp index 829c402a..e0bedf91 100644 --- a/frontend/source/frontend/dialog/confirm_dialog.cpp +++ b/frontend/source/frontend/dialog/confirm_dialog.cpp @@ -20,6 +20,9 @@ #include #include +#include +#include +#include using namespace std::string_literals; namespace Snc = ScriptNuiComponents; @@ -35,6 +38,8 @@ struct ConfirmDialog::Implementation Nui::Observed showNeverAgain{false}; Nui::Observed neverAgainChecked{false}; std::string currentNeverShowAgainId; + /** @brief The onClose of the dialog on screen, empty once it ran. */ + std::function)> pendingOnClose; Implementation(std::string id, Persistence::StateHolder& stateHolder) : id{std::move(id)} @@ -63,6 +68,10 @@ ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL(ConfirmDialog); void ConfirmDialog::open(OpenOptions const& options) { + // The dialog on screen is replaced, it closes without a button. Its onClose may open another one. + while (impl_->pendingOnClose) + std::exchange(impl_->pendingOnClose, {})(std::nullopt); + if (options.neverShowAgainId.has_value()) { auto const& neverShow = impl_->stateHolder->stateCache().uiOptions.neverShowAgainDialogs; @@ -96,12 +105,18 @@ void ConfirmDialog::open(OpenOptions const& options) impl_->table.addRow({fmt::format(fmt::runtime(language->get("confirmDialog", "andMore")), totalRows - shownRows)}); impl_->text = options.text; impl_->dialog->setButtonLabels(localizedButtonLabels()); + impl_->pendingOnClose = options.onClose; impl_->dialog->open( {.styleVariant = options.styleVariant, .headerText = options.headerText, .buttons = options.buttons, .initialFocus = options.focusButton, - .onClose = options.onClose, + .onClose = + [implementation = impl_.get()](std::optional