🐛 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
This commit is contained in:
Eva Marco 2026-10-08 11:45:35 +02:00 committed by GitHub
parent 31a079a7a2
commit c9146a30ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 64 additions and 21 deletions

View File

@ -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)

View File

@ -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)))))))

View File

@ -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)