From c9146a30ab7d10f0be6e26533c2c91eb5d8bc42b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 8 Oct 2026 11:45:35 +0200 Subject: [PATCH] :bug: Keep layer search and tree scroll across sidebar tab switches (#12117) Switching sidebar tabs unmounts the layers panel. The search text and filters lived only in component state, so on return the still open search bar was treated as a new search and cleared. Keep that state per file in a session atom, as the assets panel already does. Closing or clearing the search mounts the tree container again, but the restore hook only ran on mount or page change, so the tree went back to the top. Let use-restore-scroll take an active? flag so each list restores its own position whenever it is shown again, and drop the saved search position on close so a new search starts at the top. Closes #11972 AI-assisted-by: claude-opus-5-5 --- .../app/main/ui/workspace/sidebar/layers.cljs | 46 ++++++++++++++----- .../app/main/ui/workspace/sidebar/scroll.cljs | 31 +++++++++---- .../ui/sidebar_scroll_test.cljs | 8 ++++ 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 9ce9e18d78..5525364ee3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -19,6 +19,7 @@ [app.main.store :as st] [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.controls.input :refer [input*]] @@ -260,18 +261,30 @@ [:span {:class (stl/css :radio-text)} text]]) +(def ^:private default-search-state + {:show-search false + :find-replace-mode? false + :search-scope :layers + :show-menu false + :search-text "" + :replace-text "" + :filters #{} + :num-items 100 + :current-match-idx 0}) + +;; Per-file, session-scoped (in-memory only) so an open search and its +;; filters survive switching between the sidebar tabs, which unmounts the +;; layers panel, without leaking across files or persisting across reloads. +(defonce ^:private session-search* + (atom {})) + (defn use-search [page objects] - (let [state* (mf/use-state - #(do {:show-search false - :find-replace-mode? false - :search-scope :layers - :show-menu false - :search-text "" - :replace-text "" - :filters #{} - :num-items 100 - :current-match-idx 0})) + (let [file-id (mf/use-ctx ctx/current-file-id) + state* (mf/use-state + #(-> default-search-state + (merge (get @session-search* file-id)) + (assoc :show-menu false))) layers-search (mf/deref ref:layers-search) state (deref state*) current-filters (:filters state) @@ -522,6 +535,9 @@ (events/unlistenByKey key1) (events/unlistenByKey key2)))) + (mf/with-effect [file-id state] + (swap! session-search* assoc file-id state)) + (mf/with-effect [layers-search] (if-let [{:keys [open? find-replace-mode? scope]} layers-search] (when open? @@ -821,6 +837,8 @@ [filtered-objects show-more filter-component] (use-search page objects) + searching? (some? filtered-objects) + intersection-callback (fn [entries] (when (and (.-isIntersecting (first entries)) (some? show-more)) @@ -864,8 +882,12 @@ (mf/use-fn #(st/emit! (dw/toggle-focus-mode)))] - (sc/use-restore-scroll scroll-store :layers page-id tree-ref) - (sc/use-restore-scroll scroll-store :layers-search page-id search-ref) + (sc/use-restore-scroll scroll-store :layers page-id tree-ref (not searching?)) + (sc/use-restore-scroll scroll-store :layers-search page-id search-ref searching?) + + (mf/with-effect [page-id searching?] + (when-not ^boolean searching? + (sc/forget-scroll! scroll-store [:layers-search page-id]))) [:div {:id "layers" :class (stl/css :layers) diff --git a/frontend/src/app/main/ui/workspace/sidebar/scroll.cljs b/frontend/src/app/main/ui/workspace/sidebar/scroll.cljs index 235e29dec5..1902c9798f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/scroll.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/scroll.cljs @@ -27,6 +27,12 @@ (when-let [target (dom/get-target event)] (swap! store* assoc k (.-scrollTop target)))) +(defn forget-scroll! + "Drops the position saved under k, so the next restore starts at the + top. Plain swap!, never triggers renders." + [store* k] + (swap! store* dissoc k)) + (defn needs-restore-retry? "Pure decision for the restore loop: keep retrying while the content height is still changing (lazily rendered lists) and attempts remain." @@ -59,12 +65,19 @@ panel or id change. Retries while the content height keeps changing so deep positions in lazily rendered lists are not clamped to the first chunk. With no saved position the node goes to the top. Missing node is - a silent no-op." - [store* panel id node-ref] - (mf/with-effect [panel id] - (let [raf* (volatile! nil)] - (when-let [node (mf/ref-val node-ref)] - (restore-position! node (get @store* [panel id]) raf*)) - (fn [] - (when-some [raf @raf*] - (tm/cancel-af! raf)))))) + a silent no-op. + + `active?` is for panels that swap lists in place: pass whether the list + behind node-ref is shown, so its position is restored again each time + it comes back. Nothing is restored while it is false." + ([store* panel id node-ref] + (use-restore-scroll store* panel id node-ref true)) + ([store* panel id node-ref active?] + (mf/with-effect [panel id active?] + (let [raf* (volatile! nil)] + (when ^boolean active? + (when-let [node (mf/ref-val node-ref)] + (restore-position! node (get @store* [panel id]) raf*))) + (fn [] + (when-some [raf @raf*] + (tm/cancel-af! raf))))))) diff --git a/frontend/test/frontend_tests/ui/sidebar_scroll_test.cljs b/frontend/test/frontend_tests/ui/sidebar_scroll_test.cljs index 609ed6c35b..a1c8febc7b 100644 --- a/frontend/test/frontend_tests/ui/sidebar_scroll_test.cljs +++ b/frontend/test/frontend_tests/ui/sidebar_scroll_test.cljs @@ -31,6 +31,14 @@ (sc/save-scroll! store [:layers :page-1] #js {}) (t/is (= {} @store)))) +(t/deftest forget-scroll-drops-only-that-key + (let [store (atom {[:layers :page-1] 250 + [:layers-search :page-1] 90})] + (sc/forget-scroll! store [:layers-search :page-1]) + (t/is (= {[:layers :page-1] 250} @store)) + (sc/forget-scroll! store [:layers-search :page-1]) + (t/is (= {[:layers :page-1] 250} @store)))) + (t/deftest restore-scroll-writes-saved-position (let [node #js {:scrollTop 0}] (sc/restore-scroll! node 320)